feat(gui): introduce NeferGUI Wayland GUI library
- commit
- 52032070e74c511ede8a75889a801a7bde6d1fed
- author
- bnema · · 2026-09-30 03:09 UTC
- parent
- none (root commit)
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
255 files · +142648 -0
this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.
A .gitignore +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | /bin/ | |
| 2 | + | /coverage/ | |
| 3 | + | *.out | |
| 4 | + | /.worktrees/ | |
A LICENSE +674 -0
| @@ -0,0 +1,674 @@ | |||
| 1 | + | GNU GENERAL PUBLIC LICENSE | |
| 2 | + | Version 3, 29 June 2007 | |
| 3 | + | ||
| 4 | + | Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | |
| 5 | + | Everyone is permitted to copy and distribute verbatim copies | |
| 6 | + | of this license document, but changing it is not allowed. | |
| 7 | + | ||
| 8 | + | Preamble | |
| 9 | + | ||
| 10 | + | The GNU General Public License is a free, copyleft license for | |
| 11 | + | software and other kinds of works. | |
| 12 | + | ||
| 13 | + | The licenses for most software and other practical works are designed | |
| 14 | + | to take away your freedom to share and change the works. By contrast, | |
| 15 | + | the GNU General Public License is intended to guarantee your freedom to | |
| 16 | + | share and change all versions of a program--to make sure it remains free | |
| 17 | + | software for all its users. We, the Free Software Foundation, use the | |
| 18 | + | GNU General Public License for most of our software; it applies also to | |
| 19 | + | any other work released this way by its authors. You can apply it to | |
| 20 | + | your programs, too. | |
| 21 | + | ||
| 22 | + | When we speak of free software, we are referring to freedom, not | |
| 23 | + | price. Our General Public Licenses are designed to make sure that you | |
| 24 | + | have the freedom to distribute copies of free software (and charge for | |
| 25 | + | them if you wish), that you receive source code or can get it if you | |
| 26 | + | want it, that you can change the software or use pieces of it in new | |
| 27 | + | free programs, and that you know you can do these things. | |
| 28 | + | ||
| 29 | + | To protect your rights, we need to prevent others from denying you | |
| 30 | + | these rights or asking you to surrender the rights. Therefore, you have | |
| 31 | + | certain responsibilities if you distribute copies of the software, or if | |
| 32 | + | you modify it: responsibilities to respect the freedom of others. | |
| 33 | + | ||
| 34 | + | For example, if you distribute copies of such a program, whether | |
| 35 | + | gratis or for a fee, you must pass on to the recipients the same | |
| 36 | + | freedoms that you received. You must make sure that they, too, receive | |
| 37 | + | or can get the source code. And you must show them these terms so they | |
| 38 | + | know their rights. | |
| 39 | + | ||
| 40 | + | Developers that use the GNU GPL protect your rights with two steps: | |
| 41 | + | (1) assert copyright on the software, and (2) offer you this License | |
| 42 | + | giving you legal permission to copy, distribute and/or modify it. | |
| 43 | + | ||
| 44 | + | For the developers' and authors' protection, the GPL clearly explains | |
| 45 | + | that there is no warranty for this free software. For both users' and | |
| 46 | + | authors' sake, the GPL requires that modified versions be marked as | |
| 47 | + | changed, so that their problems will not be attributed erroneously to | |
| 48 | + | authors of previous versions. | |
| 49 | + | ||
| 50 | + | Some devices are designed to deny users access to install or run | |
| 51 | + | modified versions of the software inside them, although the manufacturer | |
| 52 | + | can do so. This is fundamentally incompatible with the aim of | |
| 53 | + | protecting users' freedom to change the software. The systematic | |
| 54 | + | pattern of such abuse occurs in the area of products for individuals to | |
| 55 | + | use, which is precisely where it is most unacceptable. Therefore, we | |
| 56 | + | have designed this version of the GPL to prohibit the practice for those | |
| 57 | + | products. If such problems arise substantially in other domains, we | |
| 58 | + | stand ready to extend this provision to those domains in future versions | |
| 59 | + | of the GPL, as needed to protect the freedom of users. | |
| 60 | + | ||
| 61 | + | Finally, every program is threatened constantly by software patents. | |
| 62 | + | States should not allow patents to restrict development and use of | |
| 63 | + | software on general-purpose computers, but in those that do, we wish to | |
| 64 | + | avoid the special danger that patents applied to a free program could | |
| 65 | + | make it effectively proprietary. To prevent this, the GPL assures that | |
| 66 | + | patents cannot be used to render the program non-free. | |
| 67 | + | ||
| 68 | + | The precise terms and conditions for copying, distribution and | |
| 69 | + | modification follow. | |
| 70 | + | ||
| 71 | + | TERMS AND CONDITIONS | |
| 72 | + | ||
| 73 | + | 0. Definitions. | |
| 74 | + | ||
| 75 | + | "This License" refers to version 3 of the GNU General Public License. | |
| 76 | + | ||
| 77 | + | "Copyright" also means copyright-like laws that apply to other kinds of | |
| 78 | + | works, such as semiconductor masks. | |
| 79 | + | ||
| 80 | + | "The Program" refers to any copyrightable work licensed under this | |
| 81 | + | License. Each licensee is addressed as "you". "Licensees" and | |
| 82 | + | "recipients" may be individuals or organizations. | |
| 83 | + | ||
| 84 | + | To "modify" a work means to copy from or adapt all or part of the work | |
| 85 | + | in a fashion requiring copyright permission, other than the making of an | |
| 86 | + | exact copy. The resulting work is called a "modified version" of the | |
| 87 | + | earlier work or a work "based on" the earlier work. | |
| 88 | + | ||
| 89 | + | A "covered work" means either the unmodified Program or a work based | |
| 90 | + | on the Program. | |
| 91 | + | ||
| 92 | + | To "propagate" a work means to do anything with it that, without | |
| 93 | + | permission, would make you directly or secondarily liable for | |
| 94 | + | infringement under applicable copyright law, except executing it on a | |
| 95 | + | computer or modifying a private copy. Propagation includes copying, | |
| 96 | + | distribution (with or without modification), making available to the | |
| 97 | + | public, and in some countries other activities as well. | |
| 98 | + | ||
| 99 | + | To "convey" a work means any kind of propagation that enables other | |
| 100 | + | parties to make or receive copies. Mere interaction with a user through | |
| 101 | + | a computer network, with no transfer of a copy, is not conveying. | |
| 102 | + | ||
| 103 | + | An interactive user interface displays "Appropriate Legal Notices" | |
| 104 | + | to the extent that it includes a convenient and prominently visible | |
| 105 | + | feature that (1) displays an appropriate copyright notice, and (2) | |
| 106 | + | tells the user that there is no warranty for the work (except to the | |
| 107 | + | extent that warranties are provided), that licensees may convey the | |
| 108 | + | work under this License, and how to view a copy of this License. If | |
| 109 | + | the interface presents a list of user commands or options, such as a | |
| 110 | + | menu, a prominent item in the list meets this criterion. | |
| 111 | + | ||
| 112 | + | 1. Source Code. | |
| 113 | + | ||
| 114 | + | The "source code" for a work means the preferred form of the work | |
| 115 | + | for making modifications to it. "Object code" means any non-source | |
| 116 | + | form of a work. | |
| 117 | + | ||
| 118 | + | A "Standard Interface" means an interface that either is an official | |
| 119 | + | standard defined by a recognized standards body, or, in the case of | |
| 120 | + | interfaces specified for a particular programming language, one that | |
| 121 | + | is widely used among developers working in that language. | |
| 122 | + | ||
| 123 | + | The "System Libraries" of an executable work include anything, other | |
| 124 | + | than the work as a whole, that (a) is included in the normal form of | |
| 125 | + | packaging a Major Component, but which is not part of that Major | |
| 126 | + | Component, and (b) serves only to enable use of the work with that | |
| 127 | + | Major Component, or to implement a Standard Interface for which an | |
| 128 | + | implementation is available to the public in source code form. A | |
| 129 | + | "Major Component", in this context, means a major essential component | |
| 130 | + | (kernel, window system, and so on) of the specific operating system | |
| 131 | + | (if any) on which the executable work runs, or a compiler used to | |
| 132 | + | produce the work, or an object code interpreter used to run it. | |
| 133 | + | ||
| 134 | + | The "Corresponding Source" for a work in object code form means all | |
| 135 | + | the source code needed to generate, install, and (for an executable | |
| 136 | + | work) run the object code and to modify the work, including scripts to | |
| 137 | + | control those activities. However, it does not include the work's | |
| 138 | + | System Libraries, or general-purpose tools or generally available free | |
| 139 | + | programs which are used unmodified in performing those activities but | |
| 140 | + | which are not part of the work. For example, Corresponding Source | |
| 141 | + | includes interface definition files associated with source files for | |
| 142 | + | the work, and the source code for shared libraries and dynamically | |
| 143 | + | linked subprograms that the work is specifically designed to require, | |
| 144 | + | such as by intimate data communication or control flow between those | |
| 145 | + | subprograms and other parts of the work. | |
| 146 | + | ||
| 147 | + | The Corresponding Source need not include anything that users | |
| 148 | + | can regenerate automatically from other parts of the Corresponding | |
| 149 | + | Source. | |
| 150 | + | ||
| 151 | + | The Corresponding Source for a work in source code form is that | |
| 152 | + | same work. | |
| 153 | + | ||
| 154 | + | 2. Basic Permissions. | |
| 155 | + | ||
| 156 | + | All rights granted under this License are granted for the term of | |
| 157 | + | copyright on the Program, and are irrevocable provided the stated | |
| 158 | + | conditions are met. This License explicitly affirms your unlimited | |
| 159 | + | permission to run the unmodified Program. The output from running a | |
| 160 | + | covered work is covered by this License only if the output, given its | |
| 161 | + | content, constitutes a covered work. This License acknowledges your | |
| 162 | + | rights of fair use or other equivalent, as provided by copyright law. | |
| 163 | + | ||
| 164 | + | You may make, run and propagate covered works that you do not | |
| 165 | + | convey, without conditions so long as your license otherwise remains | |
| 166 | + | in force. You may convey covered works to others for the sole purpose | |
| 167 | + | of having them make modifications exclusively for you, or provide you | |
| 168 | + | with facilities for running those works, provided that you comply with | |
| 169 | + | the terms of this License in conveying all material for which you do | |
| 170 | + | not control copyright. Those thus making or running the covered works | |
| 171 | + | for you must do so exclusively on your behalf, under your direction | |
| 172 | + | and control, on terms that prohibit them from making any copies of | |
| 173 | + | your copyrighted material outside their relationship with you. | |
| 174 | + | ||
| 175 | + | Conveying under any other circumstances is permitted solely under | |
| 176 | + | the conditions stated below. Sublicensing is not allowed; section 10 | |
| 177 | + | makes it unnecessary. | |
| 178 | + | ||
| 179 | + | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. | |
| 180 | + | ||
| 181 | + | No covered work shall be deemed part of an effective technological | |
| 182 | + | measure under any applicable law fulfilling obligations under article | |
| 183 | + | 11 of the WIPO copyright treaty adopted on 20 December 1996, or | |
| 184 | + | similar laws prohibiting or restricting circumvention of such | |
| 185 | + | measures. | |
| 186 | + | ||
| 187 | + | When you convey a covered work, you waive any legal power to forbid | |
| 188 | + | circumvention of technological measures to the extent such circumvention | |
| 189 | + | is effected by exercising rights under this License with respect to | |
| 190 | + | the covered work, and you disclaim any intention to limit operation or | |
| 191 | + | modification of the work as a means of enforcing, against the work's | |
| 192 | + | users, your or third parties' legal rights to forbid circumvention of | |
| 193 | + | technological measures. | |
| 194 | + | ||
| 195 | + | 4. Conveying Verbatim Copies. | |
| 196 | + | ||
| 197 | + | You may convey verbatim copies of the Program's source code as you | |
| 198 | + | receive it, in any medium, provided that you conspicuously and | |
| 199 | + | appropriately publish on each copy an appropriate copyright notice; | |
| 200 | + | keep intact all notices stating that this License and any | |
| 201 | + | non-permissive terms added in accord with section 7 apply to the code; | |
| 202 | + | keep intact all notices of the absence of any warranty; and give all | |
| 203 | + | recipients a copy of this License along with the Program. | |
| 204 | + | ||
| 205 | + | You may charge any price or no price for each copy that you convey, | |
| 206 | + | and you may offer support or warranty protection for a fee. | |
| 207 | + | ||
| 208 | + | 5. Conveying Modified Source Versions. | |
| 209 | + | ||
| 210 | + | You may convey a work based on the Program, or the modifications to | |
| 211 | + | produce it from the Program, in the form of source code under the | |
| 212 | + | terms of section 4, provided that you also meet all of these conditions: | |
| 213 | + | ||
| 214 | + | a) The work must carry prominent notices stating that you modified | |
| 215 | + | it, and giving a relevant date. | |
| 216 | + | ||
| 217 | + | b) The work must carry prominent notices stating that it is | |
| 218 | + | released under this License and any conditions added under section | |
| 219 | + | 7. This requirement modifies the requirement in section 4 to | |
| 220 | + | "keep intact all notices". | |
| 221 | + | ||
| 222 | + | c) You must license the entire work, as a whole, under this | |
| 223 | + | License to anyone who comes into possession of a copy. This | |
| 224 | + | License will therefore apply, along with any applicable section 7 | |
| 225 | + | additional terms, to the whole of the work, and all its parts, | |
| 226 | + | regardless of how they are packaged. This License gives no | |
| 227 | + | permission to license the work in any other way, but it does not | |
| 228 | + | invalidate such permission if you have separately received it. | |
| 229 | + | ||
| 230 | + | d) If the work has interactive user interfaces, each must display | |
| 231 | + | Appropriate Legal Notices; however, if the Program has interactive | |
| 232 | + | interfaces that do not display Appropriate Legal Notices, your | |
| 233 | + | work need not make them do so. | |
| 234 | + | ||
| 235 | + | A compilation of a covered work with other separate and independent | |
| 236 | + | works, which are not by their nature extensions of the covered work, | |
| 237 | + | and which are not combined with it such as to form a larger program, | |
| 238 | + | in or on a volume of a storage or distribution medium, is called an | |
| 239 | + | "aggregate" if the compilation and its resulting copyright are not | |
| 240 | + | used to limit the access or legal rights of the compilation's users | |
| 241 | + | beyond what the individual works permit. Inclusion of a covered work | |
| 242 | + | in an aggregate does not cause this License to apply to the other | |
| 243 | + | parts of the aggregate. | |
| 244 | + | ||
| 245 | + | 6. Conveying Non-Source Forms. | |
| 246 | + | ||
| 247 | + | You may convey a covered work in object code form under the terms | |
| 248 | + | of sections 4 and 5, provided that you also convey the | |
| 249 | + | machine-readable Corresponding Source under the terms of this License, | |
| 250 | + | in one of these ways: | |
| 251 | + | ||
| 252 | + | a) Convey the object code in, or embodied in, a physical product | |
| 253 | + | (including a physical distribution medium), accompanied by the | |
| 254 | + | Corresponding Source fixed on a durable physical medium | |
| 255 | + | customarily used for software interchange. | |
| 256 | + | ||
| 257 | + | b) Convey the object code in, or embodied in, a physical product | |
| 258 | + | (including a physical distribution medium), accompanied by a | |
| 259 | + | written offer, valid for at least three years and valid for as | |
| 260 | + | long as you offer spare parts or customer support for that product | |
| 261 | + | model, to give anyone who possesses the object code either (1) a | |
| 262 | + | copy of the Corresponding Source for all the software in the | |
| 263 | + | product that is covered by this License, on a durable physical | |
| 264 | + | medium customarily used for software interchange, for a price no | |
| 265 | + | more than your reasonable cost of physically performing this | |
| 266 | + | conveying of source, or (2) access to copy the | |
| 267 | + | Corresponding Source from a network server at no charge. | |
| 268 | + | ||
| 269 | + | c) Convey individual copies of the object code with a copy of the | |
| 270 | + | written offer to provide the Corresponding Source. This | |
| 271 | + | alternative is allowed only occasionally and noncommercially, and | |
| 272 | + | only if you received the object code with such an offer, in accord | |
| 273 | + | with subsection 6b. | |
| 274 | + | ||
| 275 | + | d) Convey the object code by offering access from a designated | |
| 276 | + | place (gratis or for a charge), and offer equivalent access to the | |
| 277 | + | Corresponding Source in the same way through the same place at no | |
| 278 | + | further charge. You need not require recipients to copy the | |
| 279 | + | Corresponding Source along with the object code. If the place to | |
| 280 | + | copy the object code is a network server, the Corresponding Source | |
| 281 | + | may be on a different server (operated by you or a third party) | |
| 282 | + | that supports equivalent copying facilities, provided you maintain | |
| 283 | + | clear directions next to the object code saying where to find the | |
| 284 | + | Corresponding Source. Regardless of what server hosts the | |
| 285 | + | Corresponding Source, you remain obligated to ensure that it is | |
| 286 | + | available for as long as needed to satisfy these requirements. | |
| 287 | + | ||
| 288 | + | e) Convey the object code using peer-to-peer transmission, provided | |
| 289 | + | you inform other peers where the object code and Corresponding | |
| 290 | + | Source of the work are being offered to the general public at no | |
| 291 | + | charge under subsection 6d. | |
| 292 | + | ||
| 293 | + | A separable portion of the object code, whose source code is excluded | |
| 294 | + | from the Corresponding Source as a System Library, need not be | |
| 295 | + | included in conveying the object code work. | |
| 296 | + | ||
| 297 | + | A "User Product" is either (1) a "consumer product", which means any | |
| 298 | + | tangible personal property which is normally used for personal, family, | |
| 299 | + | or household purposes, or (2) anything designed or sold for incorporation | |
| 300 | + | into a dwelling. In determining whether a product is a consumer product, | |
| 301 | + | doubtful cases shall be resolved in favor of coverage. For a particular | |
| 302 | + | product received by a particular user, "normally used" refers to a | |
| 303 | + | typical or common use of that class of product, regardless of the status | |
| 304 | + | of the particular user or of the way in which the particular user | |
| 305 | + | actually uses, or expects or is expected to use, the product. A product | |
| 306 | + | is a consumer product regardless of whether the product has substantial | |
| 307 | + | commercial, industrial or non-consumer uses, unless such uses represent | |
| 308 | + | the only significant mode of use of the product. | |
| 309 | + | ||
| 310 | + | "Installation Information" for a User Product means any methods, | |
| 311 | + | procedures, authorization keys, or other information required to install | |
| 312 | + | and execute modified versions of a covered work in that User Product from | |
| 313 | + | a modified version of its Corresponding Source. The information must | |
| 314 | + | suffice to ensure that the continued functioning of the modified object | |
| 315 | + | code is in no case prevented or interfered with solely because | |
| 316 | + | modification has been made. | |
| 317 | + | ||
| 318 | + | If you convey an object code work under this section in, or with, or | |
| 319 | + | specifically for use in, a User Product, and the conveying occurs as | |
| 320 | + | part of a transaction in which the right of possession and use of the | |
| 321 | + | User Product is transferred to the recipient in perpetuity or for a | |
| 322 | + | fixed term (regardless of how the transaction is characterized), the | |
| 323 | + | Corresponding Source conveyed under this section must be accompanied | |
| 324 | + | by the Installation Information. But this requirement does not apply | |
| 325 | + | if neither you nor any third party retains the ability to install | |
| 326 | + | modified object code on the User Product (for example, the work has | |
| 327 | + | been installed in ROM). | |
| 328 | + | ||
| 329 | + | The requirement to provide Installation Information does not include a | |
| 330 | + | requirement to continue to provide support service, warranty, or updates | |
| 331 | + | for a work that has been modified or installed by the recipient, or for | |
| 332 | + | the User Product in which it has been modified or installed. Access to a | |
| 333 | + | network may be denied when the modification itself materially and | |
| 334 | + | adversely affects the operation of the network or violates the rules and | |
| 335 | + | protocols for communication across the network. | |
| 336 | + | ||
| 337 | + | Corresponding Source conveyed, and Installation Information provided, | |
| 338 | + | in accord with this section must be in a format that is publicly | |
| 339 | + | documented (and with an implementation available to the public in | |
| 340 | + | source code form), and must require no special password or key for | |
| 341 | + | unpacking, reading or copying. | |
| 342 | + | ||
| 343 | + | 7. Additional Terms. | |
| 344 | + | ||
| 345 | + | "Additional permissions" are terms that supplement the terms of this | |
| 346 | + | License by making exceptions from one or more of its conditions. | |
| 347 | + | Additional permissions that are applicable to the entire Program shall | |
| 348 | + | be treated as though they were included in this License, to the extent | |
| 349 | + | that they are valid under applicable law. If additional permissions | |
| 350 | + | apply only to part of the Program, that part may be used separately | |
| 351 | + | under those permissions, but the entire Program remains governed by | |
| 352 | + | this License without regard to the additional permissions. | |
| 353 | + | ||
| 354 | + | When you convey a copy of a covered work, you may at your option | |
| 355 | + | remove any additional permissions from that copy, or from any part of | |
| 356 | + | it. (Additional permissions may be written to require their own | |
| 357 | + | removal in certain cases when you modify the work.) You may place | |
| 358 | + | additional permissions on material, added by you to a covered work, | |
| 359 | + | for which you have or can give appropriate copyright permission. | |
| 360 | + | ||
| 361 | + | Notwithstanding any other provision of this License, for material you | |
| 362 | + | add to a covered work, you may (if authorized by the copyright holders of | |
| 363 | + | that material) supplement the terms of this License with terms: | |
| 364 | + | ||
| 365 | + | a) Disclaiming warranty or limiting liability differently from the | |
| 366 | + | terms of sections 15 and 16 of this License; or | |
| 367 | + | ||
| 368 | + | b) Requiring preservation of specified reasonable legal notices or | |
| 369 | + | author attributions in that material or in the Appropriate Legal | |
| 370 | + | Notices displayed by works containing it; or | |
| 371 | + | ||
| 372 | + | c) Prohibiting misrepresentation of the origin of that material, or | |
| 373 | + | requiring that modified versions of such material be marked in | |
| 374 | + | reasonable ways as different from the original version; or | |
| 375 | + | ||
| 376 | + | d) Limiting the use for publicity purposes of names of licensors or | |
| 377 | + | authors of the material; or | |
| 378 | + | ||
| 379 | + | e) Declining to grant rights under trademark law for use of some | |
| 380 | + | trade names, trademarks, or service marks; or | |
| 381 | + | ||
| 382 | + | f) Requiring indemnification of licensors and authors of that | |
| 383 | + | material by anyone who conveys the material (or modified versions of | |
| 384 | + | it) with contractual assumptions of liability to the recipient, for | |
| 385 | + | any liability that these contractual assumptions directly impose on | |
| 386 | + | those licensors and authors. | |
| 387 | + | ||
| 388 | + | All other non-permissive additional terms are considered "further | |
| 389 | + | restrictions" within the meaning of section 10. If the Program as you | |
| 390 | + | received it, or any part of it, contains a notice stating that it is | |
| 391 | + | governed by this License along with a term that is a further | |
| 392 | + | restriction, you may remove that term. If a license document contains | |
| 393 | + | a further restriction but permits relicensing or conveying under this | |
| 394 | + | License, you may add to a covered work material governed by the terms | |
| 395 | + | of that license document, provided that the further restriction does | |
| 396 | + | not survive such relicensing or conveying. | |
| 397 | + | ||
| 398 | + | If you add terms to a covered work in accord with this section, you | |
| 399 | + | must place, in the relevant source files, a statement of the | |
| 400 | + | additional terms that apply to those files, or a notice indicating | |
| 401 | + | where to find the applicable terms. | |
| 402 | + | ||
| 403 | + | Additional terms, permissive or non-permissive, may be stated in the | |
| 404 | + | form of a separately written license, or stated as exceptions; | |
| 405 | + | the above requirements apply either way. | |
| 406 | + | ||
| 407 | + | 8. Termination. | |
| 408 | + | ||
| 409 | + | You may not propagate or modify a covered work except as expressly | |
| 410 | + | provided under this License. Any attempt otherwise to propagate or | |
| 411 | + | modify it is void, and will automatically terminate your rights under | |
| 412 | + | this License (including any patent licenses granted under the third | |
| 413 | + | paragraph of section 11). | |
| 414 | + | ||
| 415 | + | However, if you cease all violation of this License, then your | |
| 416 | + | license from a particular copyright holder is reinstated (a) | |
| 417 | + | provisionally, unless and until the copyright holder explicitly and | |
| 418 | + | finally terminates your license, and (b) permanently, if the copyright | |
| 419 | + | holder fails to notify you of the violation by some reasonable means | |
| 420 | + | prior to 60 days after the cessation. | |
| 421 | + | ||
| 422 | + | Moreover, your license from a particular copyright holder is | |
| 423 | + | reinstated permanently if the copyright holder notifies you of the | |
| 424 | + | violation by some reasonable means, this is the first time you have | |
| 425 | + | received notice of violation of this License (for any work) from that | |
| 426 | + | copyright holder, and you cure the violation prior to 30 days after | |
| 427 | + | your receipt of the notice. | |
| 428 | + | ||
| 429 | + | Termination of your rights under this section does not terminate the | |
| 430 | + | licenses of parties who have received copies or rights from you under | |
| 431 | + | this License. If your rights have been terminated and not permanently | |
| 432 | + | reinstated, you do not qualify to receive new licenses for the same | |
| 433 | + | material under section 10. | |
| 434 | + | ||
| 435 | + | 9. Acceptance Not Required for Having Copies. | |
| 436 | + | ||
| 437 | + | You are not required to accept this License in order to receive or | |
| 438 | + | run a copy of the Program. Ancillary propagation of a covered work | |
| 439 | + | occurring solely as a consequence of using peer-to-peer transmission | |
| 440 | + | to receive a copy likewise does not require acceptance. However, | |
| 441 | + | nothing other than this License grants you permission to propagate or | |
| 442 | + | modify any covered work. These actions infringe copyright if you do | |
| 443 | + | not accept this License. Therefore, by modifying or propagating a | |
| 444 | + | covered work, you indicate your acceptance of this License to do so. | |
| 445 | + | ||
| 446 | + | 10. Automatic Licensing of Downstream Recipients. | |
| 447 | + | ||
| 448 | + | Each time you convey a covered work, the recipient automatically | |
| 449 | + | receives a license from the original licensors, to run, modify and | |
| 450 | + | propagate that work, subject to this License. You are not responsible | |
| 451 | + | for enforcing compliance by third parties with this License. | |
| 452 | + | ||
| 453 | + | An "entity transaction" is a transaction transferring control of an | |
| 454 | + | organization, or substantially all assets of one, or subdividing an | |
| 455 | + | organization, or merging organizations. If propagation of a covered | |
| 456 | + | work results from an entity transaction, each party to that | |
| 457 | + | transaction who receives a copy of the work also receives whatever | |
| 458 | + | licenses to the work the party's predecessor in interest had or could | |
| 459 | + | give under the previous paragraph, plus a right to possession of the | |
| 460 | + | Corresponding Source of the work from the predecessor in interest, if | |
| 461 | + | the predecessor has it or can get it with reasonable efforts. | |
| 462 | + | ||
| 463 | + | You may not impose any further restrictions on the exercise of the | |
| 464 | + | rights granted or affirmed under this License. For example, you may | |
| 465 | + | not impose a license fee, royalty, or other charge for exercise of | |
| 466 | + | rights granted under this License, and you may not initiate litigation | |
| 467 | + | (including a cross-claim or counterclaim in a lawsuit) alleging that | |
| 468 | + | any patent claim is infringed by making, using, selling, offering for | |
| 469 | + | sale, or importing the Program or any portion of it. | |
| 470 | + | ||
| 471 | + | 11. Patents. | |
| 472 | + | ||
| 473 | + | A "contributor" is a copyright holder who authorizes use under this | |
| 474 | + | License of the Program or a work on which the Program is based. The | |
| 475 | + | work thus licensed is called the contributor's "contributor version". | |
| 476 | + | ||
| 477 | + | A contributor's "essential patent claims" are all patent claims | |
| 478 | + | owned or controlled by the contributor, whether already acquired or | |
| 479 | + | hereafter acquired, that would be infringed by some manner, permitted | |
| 480 | + | by this License, of making, using, or selling its contributor version, | |
| 481 | + | but do not include claims that would be infringed only as a | |
| 482 | + | consequence of further modification of the contributor version. For | |
| 483 | + | purposes of this definition, "control" includes the right to grant | |
| 484 | + | patent sublicenses in a manner consistent with the requirements of | |
| 485 | + | this License. | |
| 486 | + | ||
| 487 | + | Each contributor grants you a non-exclusive, worldwide, royalty-free | |
| 488 | + | patent license under the contributor's essential patent claims, to | |
| 489 | + | make, use, sell, offer for sale, import and otherwise run, modify and | |
| 490 | + | propagate the contents of its contributor version. | |
| 491 | + | ||
| 492 | + | In the following three paragraphs, a "patent license" is any express | |
| 493 | + | agreement or commitment, however denominated, not to enforce a patent | |
| 494 | + | (such as an express permission to practice a patent or covenant not to | |
| 495 | + | sue for patent infringement). To "grant" such a patent license to a | |
| 496 | + | party means to make such an agreement or commitment not to enforce a | |
| 497 | + | patent against the party. | |
| 498 | + | ||
| 499 | + | If you convey a covered work, knowingly relying on a patent license, | |
| 500 | + | and the Corresponding Source of the work is not available for anyone | |
| 501 | + | to copy, free of charge and under the terms of this License, through a | |
| 502 | + | publicly available network server or other readily accessible means, | |
| 503 | + | then you must either (1) cause the Corresponding Source to be so | |
| 504 | + | available, or (2) arrange to deprive yourself of the benefit of the | |
| 505 | + | patent license for this particular work, or (3) arrange, in a manner | |
| 506 | + | consistent with the requirements of this License, to extend the patent | |
| 507 | + | license to downstream recipients. "Knowingly relying" means you have | |
| 508 | + | actual knowledge that, but for the patent license, your conveying the | |
| 509 | + | covered work in a country, or your recipient's use of the covered work | |
| 510 | + | in a country, would infringe one or more identifiable patents in that | |
| 511 | + | country that you have reason to believe are valid. | |
| 512 | + | ||
| 513 | + | If, pursuant to or in connection with a single transaction or | |
| 514 | + | arrangement, you convey, or propagate by procuring conveyance of, a | |
| 515 | + | covered work, and grant a patent license to some of the parties | |
| 516 | + | receiving the covered work authorizing them to use, propagate, modify | |
| 517 | + | or convey a specific copy of the covered work, then the patent license | |
| 518 | + | you grant is automatically extended to all recipients of the covered | |
| 519 | + | work and works based on it. | |
| 520 | + | ||
| 521 | + | A patent license is "discriminatory" if it does not include within | |
| 522 | + | the scope of its coverage, prohibits the exercise of, or is | |
| 523 | + | conditioned on the non-exercise of one or more of the rights that are | |
| 524 | + | specifically granted under this License. You may not convey a covered | |
| 525 | + | work if you are a party to an arrangement with a third party that is | |
| 526 | + | in the business of distributing software, under which you make payment | |
| 527 | + | to the third party based on the extent of your activity of conveying | |
| 528 | + | the work, and under which the third party grants, to any of the | |
| 529 | + | parties who would receive the covered work from you, a discriminatory | |
| 530 | + | patent license (a) in connection with copies of the covered work | |
| 531 | + | conveyed by you (or copies made from those copies), or (b) primarily | |
| 532 | + | for and in connection with specific products or compilations that | |
| 533 | + | contain the covered work, unless you entered into that arrangement, | |
| 534 | + | or that patent license was granted, prior to 28 March 2007. | |
| 535 | + | ||
| 536 | + | Nothing in this License shall be construed as excluding or limiting | |
| 537 | + | any implied license or other defenses to infringement that may | |
| 538 | + | otherwise be available to you under applicable patent law. | |
| 539 | + | ||
| 540 | + | 12. No Surrender of Others' Freedom. | |
| 541 | + | ||
| 542 | + | If conditions are imposed on you (whether by court order, agreement or | |
| 543 | + | otherwise) that contradict the conditions of this License, they do not | |
| 544 | + | excuse you from the conditions of this License. If you cannot convey a | |
| 545 | + | covered work so as to satisfy simultaneously your obligations under this | |
| 546 | + | License and any other pertinent obligations, then as a consequence you may | |
| 547 | + | not convey it at all. For example, if you agree to terms that obligate you | |
| 548 | + | to collect a royalty for further conveying from those to whom you convey | |
| 549 | + | the Program, the only way you could satisfy both those terms and this | |
| 550 | + | License would be to refrain entirely from conveying the Program. | |
| 551 | + | ||
| 552 | + | 13. Use with the GNU Affero General Public License. | |
| 553 | + | ||
| 554 | + | Notwithstanding any other provision of this License, you have | |
| 555 | + | permission to link or combine any covered work with a work licensed | |
| 556 | + | under version 3 of the GNU Affero General Public License into a single | |
| 557 | + | combined work, and to convey the resulting work. The terms of this | |
| 558 | + | License will continue to apply to the part which is the covered work, | |
| 559 | + | but the special requirements of the GNU Affero General Public License, | |
| 560 | + | section 13, concerning interaction through a network will apply to the | |
| 561 | + | combination as such. | |
| 562 | + | ||
| 563 | + | 14. Revised Versions of this License. | |
| 564 | + | ||
| 565 | + | The Free Software Foundation may publish revised and/or new versions of | |
| 566 | + | the GNU General Public License from time to time. Such new versions will | |
| 567 | + | be similar in spirit to the present version, but may differ in detail to | |
| 568 | + | address new problems or concerns. | |
| 569 | + | ||
| 570 | + | Each version is given a distinguishing version number. If the | |
| 571 | + | Program specifies that a certain numbered version of the GNU General | |
| 572 | + | Public License "or any later version" applies to it, you have the | |
| 573 | + | option of following the terms and conditions either of that numbered | |
| 574 | + | version or of any later version published by the Free Software | |
| 575 | + | Foundation. If the Program does not specify a version number of the | |
| 576 | + | GNU General Public License, you may choose any version ever published | |
| 577 | + | by the Free Software Foundation. | |
| 578 | + | ||
| 579 | + | If the Program specifies that a proxy can decide which future | |
| 580 | + | versions of the GNU General Public License can be used, that proxy's | |
| 581 | + | public statement of acceptance of a version permanently authorizes you | |
| 582 | + | to choose that version for the Program. | |
| 583 | + | ||
| 584 | + | Later license versions may give you additional or different | |
| 585 | + | permissions. However, no additional obligations are imposed on any | |
| 586 | + | author or copyright holder as a result of your choosing to follow a | |
| 587 | + | later version. | |
| 588 | + | ||
| 589 | + | 15. Disclaimer of Warranty. | |
| 590 | + | ||
| 591 | + | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | |
| 592 | + | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | |
| 593 | + | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY | |
| 594 | + | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, | |
| 595 | + | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | |
| 596 | + | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM | |
| 597 | + | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF | |
| 598 | + | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. | |
| 599 | + | ||
| 600 | + | 16. Limitation of Liability. | |
| 601 | + | ||
| 602 | + | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING | |
| 603 | + | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS | |
| 604 | + | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY | |
| 605 | + | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE | |
| 606 | + | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF | |
| 607 | + | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD | |
| 608 | + | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), | |
| 609 | + | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF | |
| 610 | + | SUCH DAMAGES. | |
| 611 | + | ||
| 612 | + | 17. Interpretation of Sections 15 and 16. | |
| 613 | + | ||
| 614 | + | If the disclaimer of warranty and limitation of liability provided | |
| 615 | + | above cannot be given local legal effect according to their terms, | |
| 616 | + | reviewing courts shall apply local law that most closely approximates | |
| 617 | + | an absolute waiver of all civil liability in connection with the | |
| 618 | + | Program, unless a warranty or assumption of liability accompanies a | |
| 619 | + | copy of the Program in return for a fee. | |
| 620 | + | ||
| 621 | + | END OF TERMS AND CONDITIONS | |
| 622 | + | ||
| 623 | + | How to Apply These Terms to Your New Programs | |
| 624 | + | ||
| 625 | + | If you develop a new program, and you want it to be of the greatest | |
| 626 | + | possible use to the public, the best way to achieve this is to make it | |
| 627 | + | free software which everyone can redistribute and change under these terms. | |
| 628 | + | ||
| 629 | + | To do so, attach the following notices to the program. It is safest | |
| 630 | + | to attach them to the start of each source file to most effectively | |
| 631 | + | state the exclusion of warranty; and each file should have at least | |
| 632 | + | the "copyright" line and a pointer to where the full notice is found. | |
| 633 | + | ||
| 634 | + | <one line to give the program's name and a brief idea of what it does.> | |
| 635 | + | Copyright (C) <year> <name of author> | |
| 636 | + | ||
| 637 | + | This program is free software: you can redistribute it and/or modify | |
| 638 | + | it under the terms of the GNU General Public License as published by | |
| 639 | + | the Free Software Foundation, either version 3 of the License, or | |
| 640 | + | (at your option) any later version. | |
| 641 | + | ||
| 642 | + | This program is distributed in the hope that it will be useful, | |
| 643 | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 644 | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 645 | + | GNU General Public License for more details. | |
| 646 | + | ||
| 647 | + | You should have received a copy of the GNU General Public License | |
| 648 | + | along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| 649 | + | ||
| 650 | + | Also add information on how to contact you by electronic and paper mail. | |
| 651 | + | ||
| 652 | + | If the program does terminal interaction, make it output a short | |
| 653 | + | notice like this when it starts in an interactive mode: | |
| 654 | + | ||
| 655 | + | <program> Copyright (C) <year> <name of author> | |
| 656 | + | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. | |
| 657 | + | This is free software, and you are welcome to redistribute it | |
| 658 | + | under certain conditions; type `show c' for details. | |
| 659 | + | ||
| 660 | + | The hypothetical commands `show w' and `show c' should show the appropriate | |
| 661 | + | parts of the General Public License. Of course, your program's commands | |
| 662 | + | might be different; for a GUI interface, you would use an "about box". | |
| 663 | + | ||
| 664 | + | You should also get your employer (if you work as a programmer) or school, | |
| 665 | + | if any, to sign a "copyright disclaimer" for the program, if necessary. | |
| 666 | + | For more information on this, and how to apply and follow the GNU GPL, see | |
| 667 | + | <https://www.gnu.org/licenses/>. | |
| 668 | + | ||
| 669 | + | The GNU General Public License does not permit incorporating your program | |
| 670 | + | into proprietary programs. If your program is a subroutine library, you | |
| 671 | + | may consider it more useful to permit linking proprietary applications with | |
| 672 | + | the library. If this is what you want to do, use the GNU Lesser General | |
| 673 | + | Public License instead of this License. But first, please read | |
| 674 | + | <https://www.gnu.org/licenses/why-not-lgpl.html>. | |
A README.md +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | # NeferGUI | |
| 2 | + | ||
| 3 | + | Native immediate-mode GUI library for Go, built for Wayland with Vulkan rendering and CSS styling. | |
| 4 | + | ||
| 5 | + | ## Status | |
| 6 | + | ||
| 7 | + | NeferGUI is under active development and has no release yet. | |
| 8 | + | ||
| 9 | + | ## Usage | |
| 10 | + | ||
| 11 | + | ```go | |
| 12 | + | type Model struct{ Name, Status string } | |
| 13 | + | ||
| 14 | + | func view(f *nefergui.Frame, m *Model) { | |
| 15 | + | root := f.Root(nefergui.Class("app")) | |
| 16 | + | root.Input("Name", &m.Name, nefergui.Key("name")) | |
| 17 | + | if root.Button("Greet", nefergui.Key("greet")).Activated() { | |
| 18 | + | m.Status = "Hello " + m.Name | |
| 19 | + | } | |
| 20 | + | root.Text(m.Status) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func main() { | |
| 24 | + | var m Model | |
| 25 | + | if err := nefergui.Run(context.Background(), &m, view, nefergui.Title("Hello"), nefergui.Size(640, 480), nefergui.Styles("app.css")); err != nil { | |
| 26 | + | log.Fatal(err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ``` | |
| 30 | + | ||
| 31 | + | Runtime requirements: a Wayland compositor with linux-dmabuf and linux-drm-syncobj, `libvulkan.so.1` and `libxkbcommon.so.0`. Builds with `CGO_ENABLED=0`. See [docs/runtime.md](docs/runtime.md) and `examples/demo`. | |
| 32 | + | ||
| 33 | + | ## Documentation | |
| 34 | + | ||
| 35 | + | - [CSS properties and limitations](docs/css.md) | |
| 36 | + | - [Layout](docs/layout.md) · [Controls](docs/controls.md) · [Runtime](docs/runtime.md) | |
| 37 | + | - [Troubleshooting](docs/troubleshooting.md) · [Harness](docs/harness.md) · [Performance](docs/performance.md) | |
| 38 | + | ||
| 39 | + | ## Design records | |
| 40 | + | ||
| 41 | + | - [0002 Wayland bindings and FD ownership](docs/adr/0002-wayland-bindings-and-fd-ownership.md) | |
| 42 | + | - [0003 Vulkan presentation through DMA-BUF](docs/adr/0003-vulkan-dmabuf-presentation.md) | |
| 43 | + | - [0004 Keyboard interpretation](docs/adr/0004-keyboard.md) | |
| 44 | + | - [0005 Immediate-mode identity and events](docs/adr/0005-identity-and-events.md) | |
| 45 | + | - [0006 CSS and text scope](docs/adr/0006-css-and-text-scope.md) | |
| 46 | + | ||
| 47 | + | ## License | |
| 48 | + | ||
| 49 | + | NeferGUI is licensed under [GNU GPL v3](LICENSE). Third-party code and test fonts retain their licenses and attribution notices in their respective directories. | |
A cmd/nefergui-harness/main.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "flag" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "os/signal" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/nefergui/internal/harness" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func main() { os.Exit(run(os.Args[1:])) } | |
| 21 | + | func run(args []string) int { | |
| 22 | + | if len(args) == 0 || args[0] != "run" { | |
| 23 | + | fmt.Fprintln(os.Stderr, "usage: nefergui-harness run [--size WxH --scale 1.5 --layout fr --input script --expect expect.json --out dir --keep --allow-unpinned --timeout 15s] -- <client cmd...>") | |
| 24 | + | return harness.Usage | |
| 25 | + | } | |
| 26 | + | f := flag.NewFlagSet("run", flag.ContinueOnError) | |
| 27 | + | f.SetOutput(os.Stderr) | |
| 28 | + | size := f.String("size", "800x600", "headless size") | |
| 29 | + | scale := f.String("scale", "1", "output scale") | |
| 30 | + | layout := f.String("layout", "us", "keyboard layout") | |
| 31 | + | background := f.String("background", "#111111", "background #RRGGBB") | |
| 32 | + | input := f.String("input", "", "input script") | |
| 33 | + | expect := f.String("expect", "", "expectations JSON") | |
| 34 | + | out := f.String("out", "", "new artifact directory") | |
| 35 | + | keep := f.Bool("keep", false, "keep temporary runtime/config") | |
| 36 | + | allowUnpinned := f.Bool("allow-unpinned", false, "permit NEFERGUI_NEFERWL override") | |
| 37 | + | timeout := f.Duration("timeout", 15*time.Second, "maximum session duration") | |
| 38 | + | ready := f.Duration("ready-timeout", 8*time.Second, "socket readiness deadline") | |
| 39 | + | if err := f.Parse(args[1:]); err != nil { | |
| 40 | + | return harness.Usage | |
| 41 | + | } | |
| 42 | + | if *out == "" { | |
| 43 | + | fmt.Fprintln(os.Stderr, "--out required") | |
| 44 | + | return harness.Usage | |
| 45 | + | } | |
| 46 | + | var ex harness.Expectations | |
| 47 | + | if *expect != "" { | |
| 48 | + | data, err := os.ReadFile(*expect) | |
| 49 | + | if err != nil { | |
| 50 | + | fmt.Fprintln(os.Stderr, err) | |
| 51 | + | return harness.Usage | |
| 52 | + | } | |
| 53 | + | d := json.NewDecoder(strings.NewReader(string(data))) | |
| 54 | + | d.DisallowUnknownFields() | |
| 55 | + | if err = d.Decode(&ex); err != nil { | |
| 56 | + | fmt.Fprintln(os.Stderr, err) | |
| 57 | + | return harness.Usage | |
| 58 | + | } | |
| 59 | + | var extra any | |
| 60 | + | if err = d.Decode(&extra); !errors.Is(err, io.EOF) { | |
| 61 | + | fmt.Fprintln(os.Stderr, "unexpected JSON after expectations:", err) | |
| 62 | + | return harness.Usage | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | base := "." | |
| 66 | + | if *expect != "" { | |
| 67 | + | base = filepath.Dir(*expect) | |
| 68 | + | } | |
| 69 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 70 | + | defer stop() | |
| 71 | + | res, code := harness.Run(ctx, harness.Options{Size: *size, Scale: *scale, Layout: *layout, Background: *background, Input: *input, Out: *out, Keep: *keep, AllowUnpinned: *allowUnpinned, Timeout: *timeout, ReadyTimeout: *ready, Client: f.Args(), Expect: ex, GoldenBase: base}) | |
| 72 | + | b, _ := json.Marshal(res) | |
| 73 | + | fmt.Println(string(b)) | |
| 74 | + | return code | |
| 75 | + | } | |
A cmd/nefergui-harness/main_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestUsage(t *testing.T) { | |
| 10 | + | if code := run(nil); code != 2 { | |
| 11 | + | t.Fatalf("missing run: %d", code) | |
| 12 | + | } | |
| 13 | + | if code := run([]string{"run"}); code != 2 { | |
| 14 | + | t.Fatalf("missing out: %d", code) | |
| 15 | + | } | |
| 16 | + | dir := t.TempDir() | |
| 17 | + | p := filepath.Join(dir, "expect.json") | |
| 18 | + | for _, text := range []string{`{"probes":[],"unexpected":1}`, `{} {}`, `{"probes":[]}{`} { | |
| 19 | + | if err := os.WriteFile(p, []byte(text), 0600); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if code := run([]string{"run", "--expect", p, "--out", filepath.Join(dir, "out")}); code != 2 { | |
| 23 | + | t.Fatalf("%q: %d", text, code) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | func TestUnpinnedFlag(t *testing.T) { | |
| 28 | + | t.Setenv("NEFERGUI_NEFERWL", "/bin/true") | |
| 29 | + | dir := t.TempDir() | |
| 30 | + | if code := run([]string{"run", "--out", filepath.Join(dir, "denied")}); code != 2 { | |
| 31 | + | t.Fatalf("override without flag: %d", code) | |
| 32 | + | } | |
| 33 | + | if code := run([]string{"run", "--allow-unpinned", "--out", filepath.Join(dir, "allowed"), "--timeout", "1s", "--ready-timeout", "500ms"}); code == 2 { | |
| 34 | + | t.Fatalf("override with flag: %d", code) | |
| 35 | + | } | |
| 36 | + | } | |
A compilefail_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "os/exec" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestOptionCompileFailures(t *testing.T) { | |
| 12 | + | for _, name := range []string{"button_placeholder", "input_level"} { | |
| 13 | + | t.Run(name, func(t *testing.T) { | |
| 14 | + | src, err := os.ReadFile(filepath.Join("testdata", "compilefail", name+".go.txt")) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | // A temporary directory inside the module resolves the local package without a replace. | |
| 19 | + | dir, err := os.MkdirTemp(".", "compilefail-") | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | defer os.RemoveAll(dir) | |
| 24 | + | if err = os.WriteFile(filepath.Join(dir, "bad.go"), src, 0600); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | cmd := exec.Command("go", "test", "./"+dir) | |
| 28 | + | cmd.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0") | |
| 29 | + | out, err := cmd.CombinedOutput() | |
| 30 | + | if err == nil || !strings.Contains(string(out), "does not implement") { | |
| 31 | + | t.Fatalf("expected type error, got %v: %s", err, out) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
A controls.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "github.com/bnema/nefergui/internal/edit" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Control event values are immutable snapshots; querying them never consumes events. | |
| 10 | + | type ButtonEvent struct{ activated bool } | |
| 11 | + | ||
| 12 | + | func (e ButtonEvent) Activated() bool { return e.activated } | |
| 13 | + | ||
| 14 | + | type EditEvent struct{ changed, submitted bool } | |
| 15 | + | ||
| 16 | + | func (e EditEvent) Changed() bool { return e.changed } | |
| 17 | + | func (e EditEvent) Submitted() bool { return e.submitted } | |
| 18 | + | ||
| 19 | + | type ChangeEvent struct{ changed bool } | |
| 20 | + | ||
| 21 | + | func (e ChangeEvent) Changed() bool { return e.changed } | |
| 22 | + | func (n Node) Button(label string, options ...ButtonOption) ButtonEvent { | |
| 23 | + | child := n.buttonLike("button", "button", label, options) | |
| 24 | + | return ButtonEvent{activated: activated(child)} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (n Node) buttonLike(typ, role, label string, options []ButtonOption) Node { | |
| 28 | + | opts := make([]ContainerOption, 0, len(options)) | |
| 29 | + | for _, o := range options { | |
| 30 | + | if c, ok := o.(ContainerOption); ok { | |
| 31 | + | opts = append(opts, c) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | child := n.child(typ, role, opts) | |
| 35 | + | if !child.valid() { | |
| 36 | + | return child | |
| 37 | + | } | |
| 38 | + | child.element.text = label | |
| 39 | + | for _, o := range options { | |
| 40 | + | if _, common := o.(ContainerOption); !common { | |
| 41 | + | o.button(child.element) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | child.frame.compute(child.element) // disabled is set by control-only options | |
| 45 | + | return child | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func activated(child Node) bool { | |
| 49 | + | if !child.valid() || child.element.disabled { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | for _, input := range child.element.events { | |
| 53 | + | if input.Kind == "activate" { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | func (n Node) Input(label string, value *string, options ...EditOption) EditEvent { | |
| 60 | + | return n.editControl("input", label, value, options) | |
| 61 | + | } | |
| 62 | + | func (n Node) Textarea(label string, value *string, options ...EditOption) EditEvent { | |
| 63 | + | return n.editControl("textarea", label, value, options) | |
| 64 | + | } | |
| 65 | + | func (n Node) editControl(typ, label string, value *string, options []EditOption) EditEvent { | |
| 66 | + | opts := make([]ContainerOption, 0, len(options)) | |
| 67 | + | for _, o := range options { | |
| 68 | + | if c, ok := o.(ContainerOption); ok { | |
| 69 | + | opts = append(opts, c) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | child := n.child(typ, "textbox", opts) | |
| 73 | + | if !child.valid() { | |
| 74 | + | return EditEvent{} | |
| 75 | + | } | |
| 76 | + | child.element.text = label | |
| 77 | + | for _, o := range options { | |
| 78 | + | if _, common := o.(ContainerOption); !common { | |
| 79 | + | o.edit(child.element) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | var ev EditEvent | |
| 83 | + | if value == nil { | |
| 84 | + | return ev | |
| 85 | + | } | |
| 86 | + | id := idKey(child.element.identity) | |
| 87 | + | editor := child.frame.edits[id] | |
| 88 | + | if editor == nil { | |
| 89 | + | editor = &edit.State{} | |
| 90 | + | editor.Sync(*value) | |
| 91 | + | editor.Move(len(editor.Value), false) | |
| 92 | + | child.frame.edits[id] = editor | |
| 93 | + | } | |
| 94 | + | if editor.Value != *value { | |
| 95 | + | editor.Sync(*value) | |
| 96 | + | } | |
| 97 | + | if !child.element.disabled { | |
| 98 | + | for _, input := range child.element.events { | |
| 99 | + | switch input.Kind { | |
| 100 | + | case "edit-pointer", "edit-word": | |
| 101 | + | editor.PreferredXValid = false | |
| 102 | + | rendered := editor.Value | |
| 103 | + | if child.element.password { | |
| 104 | + | rendered = editor.Mask() | |
| 105 | + | } | |
| 106 | + | place := nearestTextBoundary(rendered, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.X, input.Y) | |
| 107 | + | if child.element.password { | |
| 108 | + | index := len(edit.Boundaries(rendered[:place])) - 1 | |
| 109 | + | place = edit.Boundaries(editor.Value)[index] | |
| 110 | + | } | |
| 111 | + | if input.Kind == "edit-word" { | |
| 112 | + | start, end := editor.WordAt(place) | |
| 113 | + | editor.Select(start, end) | |
| 114 | + | } else { | |
| 115 | + | editor.Move(place, input.Shift) | |
| 116 | + | } | |
| 117 | + | case "text": | |
| 118 | + | editor.PreferredXValid = false | |
| 119 | + | ev.changed = editor.Insert(input.Text) || ev.changed | |
| 120 | + | case "edit-key": | |
| 121 | + | if (input.Text == "up" || input.Text == "down") && typ == "textarea" { | |
| 122 | + | moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.Text == "down", input.Shift) | |
| 123 | + | } else { | |
| 124 | + | editor.PreferredXValid = false | |
| 125 | + | if input.Ctrl && input.Text == "v" && child.frame.owner != nil { | |
| 126 | + | if async, ok := child.frame.clipboard.(edit.AsyncClipboard); ok { | |
| 127 | + | if err := child.frame.owner.startPaste(async, child.element.identity); err != nil && debugDiagnostics { | |
| 128 | + | child.frame.diagnostics = append(child.frame.diagnostics, fmt.Sprintf("paste: %v", err)) | |
| 129 | + | } | |
| 130 | + | break | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ev.changed = editKey(editor, input, child.frame.clipboard, child.element.password) || ev.changed | |
| 134 | + | } | |
| 135 | + | case "paste-result": | |
| 136 | + | changed, err := editor.PasteAsync([]byte(input.Text), input.Err) | |
| 137 | + | if err != nil && debugDiagnostics { | |
| 138 | + | child.frame.diagnostics = append(child.frame.diagnostics, fmt.Sprintf("paste: %v", err)) | |
| 139 | + | } | |
| 140 | + | ev.changed = changed || ev.changed | |
| 141 | + | case "ime-done": | |
| 142 | + | ev.changed = editor.Done(input.IME) || ev.changed | |
| 143 | + | case "submit": | |
| 144 | + | ev.submitted = true | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | *value = editor.Value | |
| 149 | + | if child.element.password { | |
| 150 | + | child.element.text = editor.Mask() | |
| 151 | + | } else { | |
| 152 | + | child.element.text = editor.Value | |
| 153 | + | } | |
| 154 | + | // The composition is projected into the display list at the cursor; | |
| 155 | + | // it never enters the bound model before IME commit. | |
| 156 | + | if ev.changed { | |
| 157 | + | editor.Surround(child.frame.ime, child.element.password) | |
| 158 | + | } | |
| 159 | + | return ev | |
| 160 | + | } | |
| 161 | + | func editKey(s *edit.State, ev inputEvent, clipboard edit.Clipboard, password bool) bool { | |
| 162 | + | name := strings.ToLower(ev.Text) | |
| 163 | + | if ev.Ctrl { | |
| 164 | + | switch name { | |
| 165 | + | case "a": | |
| 166 | + | s.Select(0, len(s.Value)) | |
| 167 | + | return false | |
| 168 | + | case "c": | |
| 169 | + | _ = s.Copy(clipboard, password) | |
| 170 | + | return false | |
| 171 | + | case "x": | |
| 172 | + | changed, _ := s.Cut(clipboard, password) | |
| 173 | + | return changed | |
| 174 | + | case "v": | |
| 175 | + | changed, _ := s.Paste(clipboard) | |
| 176 | + | return changed | |
| 177 | + | case "left": | |
| 178 | + | s.Move(s.WordLeft(), ev.Shift) | |
| 179 | + | return false | |
| 180 | + | case "right": | |
| 181 | + | s.Move(s.WordRight(), ev.Shift) | |
| 182 | + | return false | |
| 183 | + | case "backspace": | |
| 184 | + | return s.Delete(true, true) | |
| 185 | + | case "delete": | |
| 186 | + | return s.Delete(false, true) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | switch name { | |
| 190 | + | case "left": | |
| 191 | + | s.Left(ev.Shift) | |
| 192 | + | case "right": | |
| 193 | + | s.Right(ev.Shift) | |
| 194 | + | case "home": | |
| 195 | + | s.Move(0, ev.Shift) | |
| 196 | + | case "end": | |
| 197 | + | s.Move(len(s.Value), ev.Shift) | |
| 198 | + | case "backspace": | |
| 199 | + | return s.Delete(true, false) | |
| 200 | + | case "delete": | |
| 201 | + | return s.Delete(false, false) | |
| 202 | + | } | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | func (n Node) Checkbox(label string, value *bool, options ...ButtonOption) ChangeEvent { | |
| 206 | + | child := n.buttonLike("checkbox", "checkbox", label, options) | |
| 207 | + | if child.valid() && value != nil { | |
| 208 | + | child.element.checked = *value | |
| 209 | + | } | |
| 210 | + | if activated(child) && value != nil { | |
| 211 | + | *value = !*value | |
| 212 | + | child.element.checked = *value | |
| 213 | + | return ChangeEvent{true} | |
| 214 | + | } | |
| 215 | + | return ChangeEvent{} | |
| 216 | + | } | |
| 217 | + | func (n Node) Text(text string, options ...ContainerOption) { | |
| 218 | + | child := n.child("text", "", options) | |
| 219 | + | if child.valid() { | |
| 220 | + | child.element.text = text | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | func (n Node) Heading(text string, options ...HeadingOption) { | |
| 224 | + | opts := make([]ContainerOption, 0, len(options)) | |
| 225 | + | for _, o := range options { | |
| 226 | + | if c, ok := o.(ContainerOption); ok { | |
| 227 | + | opts = append(opts, c) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | child := n.child("heading", "heading", opts) | |
| 231 | + | if !child.valid() { | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | child.element.text = text | |
| 235 | + | child.element.level = 2 | |
| 236 | + | for _, o := range options { | |
| 237 | + | if _, common := o.(ContainerOption); !common { | |
| 238 | + | o.heading(child.element) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | } | |
A controls_extra.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | vi "github.com/bnema/nefergui/internal/image" | |
| 5 | + | "github.com/bnema/nefergui/internal/layout" | |
| 6 | + | "image" | |
| 7 | + | "math" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Image borrows a decoded Go image. Treat its pixels as immutable while passed | |
| 12 | + | // to Image; pass a new image value to change pixels. Renderer cache identity | |
| 13 | + | // requires a comparable image.Image value (for example, *image.RGBA). | |
| 14 | + | func (n Node) Image(img image.Image, options ...ContainerOption) { | |
| 15 | + | child := n.child("image", "img", options) | |
| 16 | + | if child.valid() && img != nil { | |
| 17 | + | child.element.image = img | |
| 18 | + | if w, h, err := vi.Size(img); err == nil { | |
| 19 | + | child.element.imageSize = layout.Size{W: float64(w), H: float64(h)} | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | func (n Node) Icon(name string, options ...ContainerOption) { | |
| 24 | + | child := n.child("icon", "img", options) | |
| 25 | + | if child.valid() { | |
| 26 | + | child.element.text = name | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | func (n Node) Separator(options ...ContainerOption) { n.child("separator", "separator", options) } | |
| 30 | + | func (n Node) Spacer(options ...ContainerOption) { n.child("spacer", "", options) } | |
| 31 | + | func (n Node) Radio(label, option string, selected *string, options ...ButtonOption) ChangeEvent { | |
| 32 | + | child := n.buttonLike("radio", "radio", label, options) | |
| 33 | + | if child.valid() && selected != nil { | |
| 34 | + | child.element.checked = *selected == option | |
| 35 | + | if activated(child) && *selected != option { | |
| 36 | + | *selected = option | |
| 37 | + | child.element.checked = true | |
| 38 | + | return ChangeEvent{true} | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return ChangeEvent{} | |
| 42 | + | } | |
| 43 | + | func (n Node) Slider(label string, value *float64, min, max, step float64, options ...ValueOption) ChangeEvent { | |
| 44 | + | var common []ContainerOption | |
| 45 | + | for _, o := range options { | |
| 46 | + | if c, ok := o.(ContainerOption); ok { | |
| 47 | + | common = append(common, c) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | child := n.child("slider", "slider", common) | |
| 51 | + | if !child.valid() { | |
| 52 | + | return ChangeEvent{} | |
| 53 | + | } | |
| 54 | + | child.element.text = label | |
| 55 | + | for _, o := range options { | |
| 56 | + | if _, ok := o.(ContainerOption); !ok { | |
| 57 | + | o.valueOption(child.element) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | child.frame.compute(child.element) | |
| 61 | + | child.element.fraction = -1 | |
| 62 | + | if value != nil && finiteRange(min, max, step) { | |
| 63 | + | // Painted from the value after this call's changes. | |
| 64 | + | defer func() { child.element.fraction = sliderFraction(*value, min, max) }() | |
| 65 | + | } | |
| 66 | + | if value == nil || child.element.disabled || !finiteRange(min, max, step) { | |
| 67 | + | return ChangeEvent{} | |
| 68 | + | } | |
| 69 | + | original := *value | |
| 70 | + | next := original | |
| 71 | + | if math.IsNaN(next) || math.IsInf(next, 0) { | |
| 72 | + | next = min | |
| 73 | + | } | |
| 74 | + | next = sliderGrid(next, min, max, step) | |
| 75 | + | for _, event := range child.element.events { | |
| 76 | + | if event.Kind == "slider-pointer" { | |
| 77 | + | if event.Track.W > 0 && !math.IsNaN(event.X) && !math.IsInf(event.X, 0) { | |
| 78 | + | fraction := math.Max(0, math.Min(1, (event.X-event.Track.X)/event.Track.W)) | |
| 79 | + | next = sliderGrid(min+fraction*(max-min), min, max, step) | |
| 80 | + | } | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | if event.Kind != "slider-key" { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | switch strings.ToLower(event.Text) { | |
| 87 | + | case "left", "down": | |
| 88 | + | next = sliderGrid(next-step, min, max, step) | |
| 89 | + | case "right", "up": | |
| 90 | + | next = sliderGrid(next+step, min, max, step) | |
| 91 | + | case "page_up": | |
| 92 | + | next = sliderGrid(next+10*step, min, max, step) | |
| 93 | + | case "page_down": | |
| 94 | + | next = sliderGrid(next-10*step, min, max, step) | |
| 95 | + | case "home": | |
| 96 | + | next = min | |
| 97 | + | case "end": | |
| 98 | + | next = sliderGrid(max, min, max, step) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if (math.IsNaN(original) || math.IsInf(original, 0)) || next != original { | |
| 102 | + | *value = next | |
| 103 | + | return ChangeEvent{true} | |
| 104 | + | } | |
| 105 | + | return ChangeEvent{} | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // sliderGrid rounds to min+n*step then clamps; the final partial step is | |
| 109 | + | // intentionally not a stop, so keyboard and pointer share the same grid. | |
| 110 | + | // A relative epsilon keeps decimal steps such as 0.1 from losing their last | |
| 111 | + | // stop. When the grid is finer than float64 precision (or the span overflows), | |
| 112 | + | // the value is only clamped. | |
| 113 | + | func sliderGrid(v, min, max, step float64) float64 { | |
| 114 | + | if v <= min { | |
| 115 | + | return min | |
| 116 | + | } | |
| 117 | + | if v >= max { | |
| 118 | + | v = max | |
| 119 | + | } | |
| 120 | + | q := (max - min) / step | |
| 121 | + | if math.IsInf(q, 0) || math.IsNaN(q) || q > 1<<53 { | |
| 122 | + | return v | |
| 123 | + | } | |
| 124 | + | steps := math.Round((v - min) / step) | |
| 125 | + | limit := math.Floor(q + q*1e-12 + 1e-12) | |
| 126 | + | steps = math.Max(0, math.Min(steps, limit)) | |
| 127 | + | return math.Min(max, min+steps*step) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // sliderFraction is the painted thumb position; a non-finite value sits at | |
| 131 | + | // the minimum and an empty range at the start. | |
| 132 | + | func sliderFraction(v, min, max float64) float64 { | |
| 133 | + | if max <= min || math.IsNaN(v) || math.IsInf(v, 0) { | |
| 134 | + | return 0 | |
| 135 | + | } | |
| 136 | + | f := (v - min) / (max - min) | |
| 137 | + | if span := max - min; math.IsInf(span, 0) { | |
| 138 | + | // Only overflowing spans are halved: halving loses subnormal ranges. | |
| 139 | + | f = (v/2 - min/2) / (max/2 - min/2) | |
| 140 | + | } | |
| 141 | + | if math.IsNaN(f) { | |
| 142 | + | return 0 | |
| 143 | + | } | |
| 144 | + | return math.Max(0, math.Min(1, f)) | |
| 145 | + | } | |
| 146 | + | func finiteRange(min, max, step float64) bool { | |
| 147 | + | return !math.IsNaN(min) && !math.IsNaN(max) && !math.IsNaN(step) && !math.IsInf(min, 0) && !math.IsInf(max, 0) && !math.IsInf(step, 0) && min <= max && step > 0 | |
| 148 | + | } | |
A controls_extra_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/nefergui/internal/css" | |
| 5 | + | "github.com/bnema/nefergui/internal/edit" | |
| 6 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type clip struct{ data []byte } | |
| 11 | + | ||
| 12 | + | func (c *clip) ReadText(limit int) ([]byte, error) { return c.data, nil } | |
| 13 | + | func (c *clip) WriteText(v []byte) error { c.data = append([]byte(nil), v...); return nil } | |
| 14 | + | func TestRadioSliderAndEditingIdentity(t *testing.T) { | |
| 15 | + | r := newRuntime() | |
| 16 | + | r.styles = css.Compile(css.UA(), css.Parse(`input,radio,slider {height:20px}`)) | |
| 17 | + | var choice string | |
| 18 | + | v := 3.0 | |
| 19 | + | name := "e\u0301" | |
| 20 | + | order := []string{"a", "b"} | |
| 21 | + | view := func(f *Frame) { | |
| 22 | + | root := f.Root() | |
| 23 | + | for _, key := range order { | |
| 24 | + | root.Input(key, &name, Key(key)) | |
| 25 | + | } | |
| 26 | + | root.Radio("One", "one", &choice, Key("r1")) | |
| 27 | + | root.Radio("Two", "two", &choice, Key("r2")) | |
| 28 | + | root.Slider("level", &v, 0, 10, 1, Key("s")) | |
| 29 | + | } | |
| 30 | + | r.Build(view) | |
| 31 | + | r.Queue(inputEvent{Target: r.Target(3), Kind: "activate"}) | |
| 32 | + | r.Build(view) | |
| 33 | + | if choice != "two" { | |
| 34 | + | t.Fatal(choice) | |
| 35 | + | } | |
| 36 | + | r.route(key("Tab")) | |
| 37 | + | r.Build(view) | |
| 38 | + | for !r.state.focus.same(r.Target(4)) { | |
| 39 | + | r.route(key("Tab")) | |
| 40 | + | r.Build(view) | |
| 41 | + | if r.state.focus.same(r.Target(0)) { | |
| 42 | + | t.Fatalf("slider skipped: %v", func() []string { | |
| 43 | + | var ids []string | |
| 44 | + | for _, e := range r.focusOrder() { | |
| 45 | + | ids = append(ids, idKey(e.identity)) | |
| 46 | + | } | |
| 47 | + | return ids | |
| 48 | + | }()) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if !r.state.focus.same(r.Target(4)) { | |
| 52 | + | t.Fatalf("slider focus: focus=%s order=%v", idKey(r.state.focus), func() []string { | |
| 53 | + | var ids []string | |
| 54 | + | for _, e := range r.focusOrder() { | |
| 55 | + | ids = append(ids, idKey(e.identity)) | |
| 56 | + | } | |
| 57 | + | return ids | |
| 58 | + | }()) | |
| 59 | + | } | |
| 60 | + | r.route(key("Right")) | |
| 61 | + | r.Build(view) | |
| 62 | + | if v != 4 { | |
| 63 | + | t.Fatal(v) | |
| 64 | + | } | |
| 65 | + | r.Queue(inputEvent{Target: r.Target(0), Kind: "edit-key", Text: "backspace"}) | |
| 66 | + | r.Build(view) | |
| 67 | + | if name != "" { | |
| 68 | + | t.Fatal("grapheme delete", name) | |
| 69 | + | } | |
| 70 | + | order = []string{"b", "a"} | |
| 71 | + | r.Redraw() | |
| 72 | + | r.Build(view) | |
| 73 | + | if r.edits[idKey(r.Target(0))] == r.edits[idKey(r.Target(1))] { | |
| 74 | + | t.Fatal("state aliased") | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | func TestDisabledEditorRejectsFocusAndText(t *testing.T) { | |
| 78 | + | r := newRuntime() | |
| 79 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {height:20px}`)) | |
| 80 | + | value := "keep" | |
| 81 | + | view := func(f *Frame) { f.Root().Input("name", &value, Key("name"), Disabled(true)) } | |
| 82 | + | r.Build(view) | |
| 83 | + | r.route(key("Tab")) | |
| 84 | + | r.Build(view) | |
| 85 | + | if r.state.focus != nil { | |
| 86 | + | t.Fatalf("disabled input focused: %s", idKey(r.state.focus)) | |
| 87 | + | } | |
| 88 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}}) | |
| 89 | + | r.Build(view) | |
| 90 | + | if value != "keep" { | |
| 91 | + | t.Fatalf("disabled input edited: %q", value) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestEditFocusIMEAndClipboard(t *testing.T) { | |
| 96 | + | r := newRuntime() | |
| 97 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {height:20px}`)) | |
| 98 | + | var value string | |
| 99 | + | view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) } | |
| 100 | + | r.Build(view) | |
| 101 | + | r.clipboard = &clip{data: []byte("paste")} | |
| 102 | + | r.route(key("Tab")) | |
| 103 | + | r.Build(view) | |
| 104 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}}) | |
| 105 | + | r.Build(view) | |
| 106 | + | if value != "x" || r.committed.children[0].text != "•" { | |
| 107 | + | t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus)) | |
| 108 | + | } | |
| 109 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "候補"}}) | |
| 110 | + | r.Build(view) | |
| 111 | + | if value != "x" { | |
| 112 | + | t.Fatal(value) | |
| 113 | + | } | |
| 114 | + | r.route(platformInput{Kind: "focus-out"}) | |
| 115 | + | r.Build(view) | |
| 116 | + | if r.edits[idKey(r.Target(0))].Preedit != "" { | |
| 117 | + | t.Fatal("preedit survives blur") | |
| 118 | + | } | |
| 119 | + | } | |
A controls_label_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Every visible string a view declares must reach the display list as shaped | |
| 12 | + | // glyphs. Snapshots alone once froze labelless controls; this invariant does not | |
| 13 | + | // depend on a golden file. | |
| 14 | + | func TestDeclaredTextIsPainted(t *testing.T) { | |
| 15 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | r := newRuntime() | |
| 20 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 21 | + | r.styles = css.Compile(css.UA(), css.Parse(`input,textarea{width:200px;height:40px}`)) | |
| 22 | + | checked, choice, v := false, "", 3.0 | |
| 23 | + | name, notes, filled := "", "", "set" | |
| 24 | + | view := func(f *Frame) { | |
| 25 | + | root := f.Root() | |
| 26 | + | root.Button("Continuer", Key("button")) | |
| 27 | + | root.Checkbox("Thème sombre", &checked, Key("checkbox")) | |
| 28 | + | root.Radio("Option", "a", &choice, Key("radio")) | |
| 29 | + | root.Slider("Volume", &v, 0, 10, 1, Key("slider")) | |
| 30 | + | root.Input("Nom", &name, Key("input"), Placeholder("Ada")) | |
| 31 | + | root.Textarea("Notes", ¬es, Key("textarea"), Placeholder("Écrire…")) | |
| 32 | + | root.Input("Rempli", &filled, Key("filled"), Placeholder("hidden")) | |
| 33 | + | root.Button("Off", Key("disabled"), Disabled(true)) | |
| 34 | + | } | |
| 35 | + | r.Build(view) | |
| 36 | + | painted := map[string]layout.Command{} | |
| 37 | + | for _, cmd := range r.output.Display { | |
| 38 | + | if cmd.Op == "text" && glyphCount(cmd) > 0 { | |
| 39 | + | painted[cmd.Text] = cmd | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | for _, want := range []string{"Continuer", "Thème sombre", "Option", "Volume", "Ada", "Écrire…", "set", "Off"} { | |
| 43 | + | if _, ok := painted[want]; !ok { | |
| 44 | + | t.Errorf("%q declared but not painted; painted: %v", want, keys(painted)) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if _, ok := painted["hidden"]; ok { | |
| 48 | + | t.Error("placeholder painted over a non-empty value") | |
| 49 | + | } | |
| 50 | + | if p := painted["Ada"]; p.Opacity >= 1 || p.Opacity <= 0 { | |
| 51 | + | t.Errorf("placeholder opacity %v, want dimmed", p.Opacity) | |
| 52 | + | } | |
| 53 | + | // The placeholder never enters the model, and typing replaces it. | |
| 54 | + | if name != "" { | |
| 55 | + | t.Fatalf("placeholder leaked into value: %q", name) | |
| 56 | + | } | |
| 57 | + | r.route(key("Tab")) | |
| 58 | + | r.Build(view) | |
| 59 | + | for i := 0; i < 4; i++ { | |
| 60 | + | r.route(key("Tab")) | |
| 61 | + | r.Build(view) | |
| 62 | + | } | |
| 63 | + | r.route(typed("b", "b")) | |
| 64 | + | r.Build(view) | |
| 65 | + | for _, cmd := range r.output.Display { | |
| 66 | + | if cmd.Text == "Ada" { | |
| 67 | + | t.Fatalf("placeholder still painted after typing %q", name) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if name != "b" { | |
| 71 | + | t.Fatalf("typed into wrong control: name=%q", name) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Placeholders follow editor text geometry: text-align and RTL position, | |
| 76 | + | // letter-spacing, and textarea wrapping. | |
| 77 | + | func TestPlaceholderGeometry(t *testing.T) { | |
| 78 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | placeholder := func(sheet, typ, p string) layout.Command { | |
| 83 | + | t.Helper() | |
| 84 | + | r := newRuntime() | |
| 85 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 86 | + | r.styles = css.Compile(css.UA(), css.Parse(sheet)) | |
| 87 | + | value := "" | |
| 88 | + | r.Build(func(f *Frame) { | |
| 89 | + | if typ == "textarea" { | |
| 90 | + | f.Root().Textarea("", &value, Placeholder(p)) | |
| 91 | + | } else { | |
| 92 | + | f.Root().Input("", &value, Placeholder(p)) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | for _, cmd := range r.output.Display { | |
| 96 | + | if cmd.Text == p { | |
| 97 | + | return cmd | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | t.Fatalf("placeholder %q not painted", p) | |
| 101 | + | return layout.Command{} | |
| 102 | + | } | |
| 103 | + | span := func(cmd layout.Command) (lo, hi float64, lines int) { | |
| 104 | + | lo, hi = 1e9, -1e9 | |
| 105 | + | ys := map[float64]bool{} | |
| 106 | + | for _, run := range cmd.Runs { | |
| 107 | + | ys[run.Y] = true | |
| 108 | + | for _, g := range run.Glyphs { | |
| 109 | + | lo, hi = min(lo, g.X), max(hi, g.X+g.Advance) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return lo, hi, len(ys) | |
| 113 | + | } | |
| 114 | + | box := `input,textarea{width:300px;height:80px;font-size:16px}` | |
| 115 | + | leftLo, leftHi, _ := span(placeholder(box, "input", "Ada")) | |
| 116 | + | if leftLo > 1 { | |
| 117 | + | t.Errorf("start-aligned LTR placeholder at x=%v", leftLo) | |
| 118 | + | } | |
| 119 | + | if lo, hi, _ := span(placeholder(box+`input{text-align:right}`, "input", "Ada")); hi < 299 || lo < 200 { | |
| 120 | + | t.Errorf("right-aligned placeholder spans %v..%v", lo, hi) | |
| 121 | + | } | |
| 122 | + | if lo, hi, _ := span(placeholder(box, "input", "مرحبا")); hi < 299 || lo < 200 { | |
| 123 | + | t.Errorf("RTL start-aligned placeholder spans %v..%v", lo, hi) | |
| 124 | + | } | |
| 125 | + | if lo, hi, _ := span(placeholder(box+`input{letter-spacing:4px}`, "input", "Ada")); hi-lo < leftHi-leftLo+8 { | |
| 126 | + | t.Errorf("letter-spacing ignored: width %v vs %v", hi-lo, leftHi-leftLo) | |
| 127 | + | } | |
| 128 | + | long := "Une longue phrase d'exemple qui dépasse la largeur du champ de texte" | |
| 129 | + | if _, hi, lines := span(placeholder(box, "textarea", long)); lines < 2 || hi > 300.5 { | |
| 130 | + | t.Errorf("textarea placeholder: %d lines, right edge %v", lines, hi) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func glyphCount(cmd layout.Command) int { | |
| 135 | + | n := 0 | |
| 136 | + | for _, run := range cmd.Runs { | |
| 137 | + | n += len(run.Glyphs) | |
| 138 | + | } | |
| 139 | + | return n | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func keys(m map[string]layout.Command) []string { | |
| 143 | + | out := make([]string, 0, len(m)) | |
| 144 | + | for k := range m { | |
| 145 | + | out = append(out, k) | |
| 146 | + | } | |
| 147 | + | return out | |
| 148 | + | } | |
A controls_snapshot_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "image" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefergui/internal/css" | |
| 13 | + | "github.com/bnema/nefergui/internal/edit" | |
| 14 | + | "github.com/bnema/nefergui/internal/text" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // The golden is the serialized display list, not a rendered image; each case | |
| 18 | + | // still checks the intended pseudo-state/event before comparing exact JSON. | |
| 19 | + | func TestControlDisplaySnapshots(t *testing.T) { | |
| 20 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | controls := []string{"text", "heading", "image", "icon", "separator", "spacer", "button", "checkbox", "radio", "slider", "input", "textarea"} | |
| 25 | + | states := []string{"normal", "hover", "focus-visible", "disabled", "checked"} | |
| 26 | + | for _, kind := range controls { | |
| 27 | + | for _, state := range states { | |
| 28 | + | if (state == "checked" && kind != "checkbox" && kind != "radio") || ((state == "focus-visible" || state == "disabled") && kind != "button" && kind != "checkbox" && kind != "radio" && kind != "slider" && kind != "input" && kind != "textarea") { | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | t.Run(kind+"/"+state, func(t *testing.T) { | |
| 32 | + | r := newRuntime() | |
| 33 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 34 | + | r.styles = css.Compile(css.UA(), css.Parse(`text,heading,image,icon,separator,spacer,button,checkbox,radio,slider,input,textarea {width:120px;height:28px;font-size:16px;background:#ddeeff} button:hover,checkbox:hover,radio:hover,slider:hover,input:hover,textarea:hover {background:#aaccee} button:focus-visible,checkbox:focus-visible,radio:focus-visible,slider:focus-visible,input:focus-visible,textarea:focus-visible {outline:2px solid red} button:disabled,checkbox:disabled,radio:disabled,slider:disabled,input:disabled,textarea:disabled {opacity:0.5}`)) | |
| 35 | + | checked := state == "checked" | |
| 36 | + | choice := "" | |
| 37 | + | if checked { | |
| 38 | + | choice = "yes" | |
| 39 | + | } | |
| 40 | + | v := 3.0 | |
| 41 | + | value := "abc" | |
| 42 | + | disabled := state == "disabled" | |
| 43 | + | view := func(f *Frame) { | |
| 44 | + | root := f.Root() | |
| 45 | + | switch kind { | |
| 46 | + | case "text": | |
| 47 | + | root.Text("abc") | |
| 48 | + | case "heading": | |
| 49 | + | root.Heading("abc") | |
| 50 | + | case "image": | |
| 51 | + | root.Image(image.NewRGBA(image.Rect(0, 0, 12, 12))) | |
| 52 | + | case "icon": | |
| 53 | + | root.Icon("star") | |
| 54 | + | case "separator": | |
| 55 | + | root.Separator() | |
| 56 | + | case "spacer": | |
| 57 | + | root.Spacer() | |
| 58 | + | case "button": | |
| 59 | + | root.Button("abc", Disabled(disabled)) | |
| 60 | + | case "checkbox": | |
| 61 | + | root.Checkbox("abc", &checked, Disabled(disabled)) | |
| 62 | + | case "radio": | |
| 63 | + | root.Radio("abc", "yes", &choice, Disabled(disabled)) | |
| 64 | + | case "slider": | |
| 65 | + | root.Slider("abc", &v, 0, 10, 1, Disabled(disabled)) | |
| 66 | + | case "input": | |
| 67 | + | root.Input("abc", &value, Disabled(disabled)) | |
| 68 | + | case "textarea": | |
| 69 | + | root.Textarea("abc", &value, Disabled(disabled)) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | r.Build(view) | |
| 73 | + | switch state { | |
| 74 | + | case "hover": | |
| 75 | + | r.route(platformInput{Kind: "motion", X: 2, Y: 2}) | |
| 76 | + | r.Build(view) | |
| 77 | + | if r.state.hover == nil { | |
| 78 | + | t.Fatal("no hover") | |
| 79 | + | } | |
| 80 | + | case "focus-visible": | |
| 81 | + | r.route(key("Tab")) | |
| 82 | + | r.Build(view) | |
| 83 | + | if !r.state.visible { | |
| 84 | + | t.Fatal("no keyboard focus") | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | data, err := json.MarshalIndent(r.output.Display, "", " ") | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | assertSnapshot(t, kind+"_"+state, data) | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | for _, name := range []string{"selection-ltr", "selection-arabic", "preedit"} { | |
| 96 | + | t.Run("input/"+name, func(t *testing.T) { | |
| 97 | + | r := newRuntime() | |
| 98 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 99 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {width:120px;height:28px;font-size:16px;background:#ddeeff}`)) | |
| 100 | + | value := "abc" | |
| 101 | + | if name == "selection-arabic" { | |
| 102 | + | value = "مرحبا" | |
| 103 | + | } | |
| 104 | + | view := func(f *Frame) { f.Root().Input("", &value) } | |
| 105 | + | r.Build(view) | |
| 106 | + | r.route(key("Tab")) | |
| 107 | + | r.Build(view) | |
| 108 | + | s := r.edits[idKey(r.Target(0))] | |
| 109 | + | if name == "preedit" { | |
| 110 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "é", Begin: len("é")}}) | |
| 111 | + | r.Build(view) | |
| 112 | + | } else { | |
| 113 | + | s.Select(0, len(value)) | |
| 114 | + | r.Redraw() | |
| 115 | + | r.Build(view) | |
| 116 | + | } | |
| 117 | + | data, err := json.MarshalIndent(r.output.Display, "", " ") | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | assertSnapshot(t, "input_"+name, data) | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | func assertSnapshot(t *testing.T, name string, data []byte) { | |
| 126 | + | t.Helper() | |
| 127 | + | path := filepath.Join("testdata", "control-display", name+".json") | |
| 128 | + | if os.Getenv("NEFERGUI_WRITE_SNAPSHOTS") == "1" { | |
| 129 | + | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | want, err := os.ReadFile(path) | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatalf("missing snapshot %s: %v", name, err) | |
| 139 | + | } | |
| 140 | + | if !bytes.Equal(bytes.TrimSpace(want), bytes.TrimSpace(data)) { | |
| 141 | + | t.Fatalf("snapshot %s changed (got %d bytes, want %d): %s", name, len(data), len(want), fmt.Sprintf("first 120: %s", data[:min(len(data), 120)])) | |
| 142 | + | } | |
| 143 | + | } | |
A controls_visual_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Semantic controls must contribute CSS-computed paint rather than Go-side colors. | |
| 10 | + | func TestNewControlDisplayListJSON(t *testing.T) { | |
| 11 | + | r := newRuntime() | |
| 12 | + | var selected string | |
| 13 | + | v := 0.0 | |
| 14 | + | view := func(f *Frame) { | |
| 15 | + | n := f.Root() | |
| 16 | + | n.Separator(Key("sep")) | |
| 17 | + | n.Spacer(Key("gap")) | |
| 18 | + | n.Icon("star", Key("icon")) | |
| 19 | + | n.Radio("option", "one", &selected, Key("radio")) | |
| 20 | + | n.Slider("volume", &v, 0, 10, 1, Key("slider")) | |
| 21 | + | } | |
| 22 | + | if !r.Build(view) { | |
| 23 | + | t.Fatal("build") | |
| 24 | + | } | |
| 25 | + | data, err := json.Marshal(r.output.Display) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if !strings.Contains(string(data), `"Op":"rect","ID":"root/k3:sep"`) || !strings.Contains(string(data), `"R":0.5019607843137255`) { | |
| 30 | + | t.Fatalf("separator should use UA computed background: %s", data) | |
| 31 | + | } | |
| 32 | + | for _, id := range []string{"root/k3:sep", "root/k3:gap", "root/k4:icon", "root/k5:radio", "root/k6:slider"} { | |
| 33 | + | if resultByID(r.output.Tree, id) == nil { | |
| 34 | + | t.Fatalf("missing semantic node %s", id) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
A debug_off.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !nefergui_debug | |
| 2 | + | ||
| 3 | + | package nefergui | |
| 4 | + | ||
| 5 | + | const debugDiagnostics = false | |
A debug_on.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build nefergui_debug | |
| 2 | + | ||
| 3 | + | package nefergui | |
| 4 | + | ||
| 5 | + | const debugDiagnostics = true | |
A doc.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | // Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with | |
| 2 | + | // Vulkan and styled with a bounded CSS dialect (see docs/css.md). | |
| 3 | + | // | |
| 4 | + | // Run opens a window and calls the view function to build each frame. The view | |
| 5 | + | // receives a Frame; Frame and its Node handles are valid only during that call. | |
| 6 | + | // Key identifies a child among its siblings across frames; without a key, | |
| 7 | + | // position and type determine identity. ID is CSS metadata, not frame identity. | |
| 8 | + | // Options such as Title, Size, Styles and Transparent configure the window. | |
| 9 | + | // | |
| 10 | + | // Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1, | |
| 11 | + | // libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and | |
| 12 | + | // linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md. | |
| 13 | + | package nefergui | |
A docs/adr/0002-wayland-bindings-and-fd-ownership.md +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | # ADR 0002 — Wayland bindings and file-descriptor ownership | |
| 2 | + | ||
| 3 | + | Status: accepted | |
| 4 | + | ||
| 5 | + | ## Decision | |
| 6 | + | ||
| 7 | + | WLTurbo owns the Wayland wire transport, object registry, XML scanner and the canonical generated bindings. NeferGUI consumes generated packages and never writes protocol code. | |
| 8 | + | ||
| 9 | + | - `protocol/core` is generated from `wayland.xml`. Only `wl_display` and `wl_registry` bootstrap code is handwritten. | |
| 10 | + | - Generated child objects keep their concrete type and typed event dispatch. | |
| 11 | + | - Required extensions and the behavior when a global is missing: | |
| 12 | + | ||
| 13 | + | | Binding | Use | Missing global | | |
| 14 | + | |---|---|---| | |
| 15 | + | | xdg-shell | window lifecycle | fatal capability error | | |
| 16 | + | | linux-dmabuf v4 feedback | format and device selection | fatal capability error | | |
| 17 | + | | linux-drm-syncobj v1 | explicit synchronization | fatal capability error | | |
| 18 | + | | viewporter | fractional buffer mapping | fatal when fractional scale is active | | |
| 19 | + | | fractional-scale v1 | preferred scale | integer-scale fallback | | |
| 20 | + | | text-input v3 | IME | IME unavailable, keyboard input still works | | |
| 21 | + | | core data-device | clipboard | clipboard unavailable | | |
| 22 | + | ||
| 23 | + | Excluded: presentation-time, data-control, X11, compositor-private blur. | |
| 24 | + | ||
| 25 | + | ## File descriptors | |
| 26 | + | ||
| 27 | + | - A request that sends an FD transfers ownership only after a complete successful send. | |
| 28 | + | - A received message owns exactly the FDs declared by its signature. Socket reads are not message boundaries. | |
| 29 | + | - Missing or extra FDs, truncated ancillary data, malformed payloads and unknown signatures are protocol errors. FD 0 is valid. | |
| 30 | + | - Without a handler, owned FDs are closed. An owned FD reaches exactly one handler path. | |
| 31 | + | - Decode, dispatch, cancellation, object destruction and shutdown close every unclaimed FD. | |
A docs/adr/0003-vulkan-dmabuf-presentation.md +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | # ADR 0003 — Vulkan presentation through DMA-BUF | |
| 2 | + | ||
| 3 | + | Status: accepted | |
| 4 | + | ||
| 5 | + | ## Decision | |
| 6 | + | ||
| 7 | + | NeferGUI renders with Vulkan into exportable images and presents them as `zwp_linux_dmabuf_v1` buffers. It does not use `VK_KHR_swapchain`. | |
| 8 | + | ||
| 9 | + | - Synchronization uses `wp_linux_drm_syncobj_v1` with Vulkan timeline semaphores. There is no CPU-fence fallback. | |
| 10 | + | - The Vulkan physical device must match the `main_device` of linux-dmabuf feedback. There is no cross-GPU fallback. | |
| 11 | + | - Formats: one-plane `XRGB8888` for opaque windows (default) and `ARGB8888` for transparent windows, both mapped to `VK_FORMAT_B8G8R8A8_UNORM`, with a modifier supported by both the compositor and the GPU. | |
| 12 | + | - Three exportable images per window. | |
| 13 | + | - Shaders are GLSL compiled by `glslc` through `go generate`. SPIR-V is committed and embedded; consumers do not need `glslc`. | |
| 14 | + | ||
| 15 | + | ## Buffer lifecycle | |
| 16 | + | ||
| 17 | + | ```text | |
| 18 | + | Available → Rendering → acquire point exported → committed with acquire/release points | |
| 19 | + | → compositor reads → release point of its latest commit reached → Available | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | While a `wp_linux_drm_syncobj_surface_v1` exists, `wl_buffer.release` delivery is undefined (linux-drm-syncobj-v1). A buffer is reused only after the release point set for its most recent commit is signaled; each buffer has its own release timeline because points may signal out of order. `wl_buffer.release` is traced when it arrives but never gates reuse. Frame callbacks schedule drawing; they never authorize reuse. | |
| 23 | + | ||
| 24 | + | The UI loop never blocks in Wayland dispatch while GPU or compositor completion is pending. Blocking sources (Wayland socket, release timeline waits) run on their own goroutines and only post events to the loop, which owns all presentation state. Resize creates a new generation; old images are retired after their latest release point signals. | |
| 25 | + | ||
| 26 | + | At shutdown, release points protect reuse of contents, **not** the lifetime of exported DMA-BUF memory: kernel FD references and compositor imports are independent of the client's Vulkan allocation. Detach and commit null, destroy the syncobj surface, wait at most 200 ms for outstanding release points for tracing, wait for client GPU completion, destroy `wl_buffer`s and **all** client images/memory/semaphores/syncobj handles, then destroy the device. Never abandon Vulkan allocations solely because a compositor release point has not arrived. | |
| 27 | + | ||
| 28 | + | `Opaque()` sets a full-surface opaque region. `Transparent()` uses premultiplied alpha and no opaque region. Background blur is a compositor setting, not a NeferGUI feature. | |
A docs/adr/0004-keyboard.md +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | # ADR 0004 — Keyboard interpretation | |
| 2 | + | ||
| 3 | + | Status: accepted | |
| 4 | + | ||
| 5 | + | ## Decision | |
| 6 | + | ||
| 7 | + | Keyboard interpretation uses `github.com/bnema/purego-xkbcommon`, which loads `libxkbcommon.so.0` at runtime without cgo. Raw bindings are generated from pinned upstream headers; a small handwritten layer owns reference counting, UTF-8 buffers, compose state and Wayland keymap FD ingestion. X11 APIs are not bound. | |
| 8 | + | ||
| 9 | + | NeferGUI handles keymap replacement, modifier and group masks, non-US layouts including AltGr, compose and dead keys, key repeat and its cancellation, shortcuts, and IME/keyboard deduplication. The compositor sets repeat rate and delay. | |
| 10 | + | ||
| 11 | + | While text-input v3 composition is active it takes precedence. Otherwise text comes from `xkb_state_key_get_utf8`. | |
A docs/adr/0005-identity-and-events.md +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | # ADR 0005 — Immediate-mode identity and events | |
| 2 | + | ||
| 3 | + | Status: accepted | |
| 4 | + | ||
| 5 | + | ## Identity | |
| 6 | + | ||
| 7 | + | - `Key` is a stable identity local to the parent. `ID` is CSS metadata only. | |
| 8 | + | - Without a key, identity is `parent identity + local child position + element type`. | |
| 9 | + | - Dynamic or reordered collections need explicit keys. Debug builds report duplicate sibling keys, stale `Node` use and suspicious unkeyed sequence changes. | |
| 10 | + | ||
| 11 | + | ## Events | |
| 12 | + | ||
| 13 | + | - Input is hit-tested against the last committed layout. The target identity is queued, the UI loop wakes, pending events are batched and one frame is built. | |
| 14 | + | - The event is delivered to the matching element in that frame, is idempotent while the frame runs, and expires at the end of the frame. | |
| 15 | + | - No user code runs outside frame construction. | |
| 16 | + | - Pointer-backed controls may change the pointed value during their call, never keep the pointer, and later calls in the same frame see the new value. | |
| 17 | + | - Controls declared earlier in that frame painted the previous value, so every event batch is followed by exactly one event-free frame that shows the settled state. That frame schedules nothing further. | |
A docs/adr/0006-css-and-text-scope.md +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | # ADR 0006 — CSS and text scope | |
| 2 | + | ||
| 3 | + | Status: accepted | |
| 4 | + | ||
| 5 | + | ## CSS | |
| 6 | + | ||
| 7 | + | - Units: logical `px`, `%`, `em`, `rem`, unitless zero and `auto`. A NeferGUI `px` is a logical pixel; the renderer converts to physical pixels with the Wayland scale. | |
| 8 | + | - Not supported: viewport and physical units, `calc`, `min`, `max`, `clamp`. | |
| 9 | + | - Selectors: type, class, ID, descendant, direct child; pseudo-classes `hover`, `active`, `focus`, `focus-visible`, `disabled`. | |
| 10 | + | - A property table is frozen before parser work (`docs/css.md`). Unknown declarations are ignored with a diagnostic. A malformed rule never corrupts its neighbours. | |
| 11 | + | - `Row` and `Column` ship overridable flex defaults. `Stack` paints its first child at the back, sizes to the largest child and hit-tests in reverse paint order. Semantic helpers set a CSS type and an accessibility role. | |
| 12 | + | ||
| 13 | + | ## Text and images | |
| 14 | + | ||
| 15 | + | - `go-text/typesetting` handles font parsing, segmentation, bidi, fallback and shaping. | |
| 16 | + | - `golang.org/x/image/font/sfnt` with a Go rasterizer produces grayscale masks. | |
| 17 | + | - Glyph positions stay fractional. Atlas keys include face, glyph, physical size, variation coordinates and subpixel phase. | |
| 18 | + | - Mono-channel bounded atlases, premultiplied alpha, linear-light blending, sRGB output. No LCD or SDF/MSDF text. | |
| 19 | + | - Font fixtures are pinned with their licenses. Golden images need explicit human approval and are backed by independent metric and coverage checks. | |
| 20 | + | - Images: Go `image.Image`, PNG and JPEG from the standard library. No SVG or animation. | |
A docs/controls.md +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | # Controls | |
| 2 | + | ||
| 3 | + | `Text`, `Heading`, `Image`, `Icon`, `Separator`, `Spacer`, `Button`, `Checkbox`, `Radio`, `Slider`, `Input` and `Textarea` build semantic nodes in the immediate frame. `Key` identifies reorderable controls; `ID` and `Class` are CSS metadata. `Image` accepts an existing `image.Image` with bounded intrinsic dimensions. Style is computed via the existing CSS cascade; the caller owns images. CSS types and default accessibility roles are assigned even though a platform accessibility adapter is not yet connected. | |
| 4 | + | ||
| 5 | + | `Button`, `Checkbox`, `Radio` and `Slider` paint their label as text in the control's content box, using its font, `color` and `text-align` (buttons center by default). | |
| 6 | + | ||
| 7 | + | Checkbox and radio paint a one-em indicator, centered vertically, in their left padding (UA `padding-left: 1.5em`). Unchecked, it is an outline in `color` at 45% alpha. Checked, it is filled with `accent-color` and carries a white mark: a rounded square for checkboxes, a dot for radios. The slider paints, in its bottom padding (UA `padding-bottom: 1.25em`), a rail across the content width the pointer maps to, filled with `accent-color` up to the value, plus a round `accent-color` thumb. The indicator size follows `font-size`; `accent-color` (UA default `#3584e4`), `color`, `opacity` and `:checked` let author CSS restyle them, for example `checkbox:checked { accent-color: green }`. The painter fixes the rest: shapes and proportions, the white mark and the 45% outline alpha. Indicators stay visible when the control clips its content. The indicator always sits at the left, including for RTL labels. A slider with no bottom padding paints no track; checkbox and radio indicators still paint at the left edge, so keep their left padding. Disabled checkboxes, radios and sliders are dimmed by UA `opacity: 0.5`. | |
| 8 | + | ||
| 9 | + | The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`. | |
| 10 | + | ||
| 11 | + | `Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries the preceding run supplies the caret edge. No undo/redo is implemented. | |
| 12 | + | ||
| 13 | + | `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter. | |
| 14 | + | ||
| 15 | + | Display-list JSON snapshots under `testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list. | |
| 16 | + | ||
| 17 | + | The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above. | |
| 18 | + | ||
| 19 | + | Sliders are left-to-right: minimum is at the left edge. CSS `direction` is not part of the supported CSS dialect; this is an explicit limitation, not an inferred text direction. Primary-pointer press/drag/release maps the computed content-box track to the min+n*step grid, clamps at both ends (a partial final step is not a stop) and keeps capture while dragging outside the slider. Keyboard steps use the same grid; NaN/Inf values normalize to the minimum. | |
A docs/css.md +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | # NeferGUI CSS contract | |
| 2 | + | ||
| 3 | + | This is a deliberately bounded CSS dialect, not browser CSS. Identifiers and property names are ASCII case-insensitive except custom properties (case-sensitive), IDs, classes and quoted font names. Whitespace and `/* ... */` comments separate tokens. Unknown properties and invalid static values are ignored with line/column diagnostics in `Sheet.Diagnostics` (or the diagnostics returned by `ParseInline`). Declarations remain in parsed sheets for inspection, but are omitted from compiled styles. Values containing `var()` are validated only at computed-value time, when substitutions are available; invalid computed values fall back as described below and do not emit compile-time diagnostics. There is no selector nesting, at-rule, animation or media query support. | |
| 4 | + | ||
| 5 | + | ## Grammar and cascade | |
| 6 | + | ||
| 7 | + | Stylesheet = repeated `selector-list { declaration* }`; declaration = `property : value [!important] ;` (last semicolon optional). A bad declaration is skipped to `;`/`}`; a bad rule is skipped through its balanced block. Selectors: type (`app`, `button`, etc.), `.class`, `#id`, compounds, whitespace descendant, `>` child, comma lists, `:hover`, `:active`, `:focus`, `:focus-visible`, `:disabled`, `:checked` (a checked checkbox or selected radio). No universal, attribute, sibling, negation, pseudo-element or functional selector. Specificity is lexicographic (IDs, classes+pseudo-classes, types); lists use the highest *matching* selector. A later declaration wins a tie. NeferGUI UA defaults have lower origin than author declarations; **author `!important` wins over all normal declarations**, then origin, specificity and source order. UA defaults do not use important. Duplicate declarations retain source order. Unset inherited properties inherit; other properties use initial. Explicit `inherit`, `initial`, `unset` are supported. Custom properties inherit by default and are resolved after cascade; `var(--name[, fallback])` can appear anywhere in a value, nested and repeated; missing/cyclic variables without fallback invalidate the consuming declaration at computed-value time (inherit for inherited properties, initial otherwise), **not** the next candidate in the cascade. Fallback can be another var. Custom property values are token sequences, not parsed until use; an empty value is valid. | |
| 8 | + | ||
| 9 | + | Units: logical `px`, `%`, `em`, `rem`, unitless `0`, `auto` where explicitly allowed. `em` resolves with this element's computed font size (font-size itself uses parent's size), `rem` with root font size; `%` stays unresolved for layout. Negative lengths only where noted. Colors: CSS named colors (full CSS named set), `transparent`, `currentColor`, `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` with comma or space channel syntax and optional `/` alpha, `hsl()`/`hsla()` similarly. RGB channels 0–255 or %, alpha 0–1 or %, hue degrees (unitless or deg), saturation/lightness %. Clamp color channels, reject non-finite numbers. Computed colors are straight sRGB RGBA float64 [0,1]; renderer converts to premultiplied linear. `currentColor` refers to computed `color`; on `color` itself it means inherited color. Lengths and opacity reject non-finite/overflow numeric input. | |
| 10 | + | ||
| 11 | + | ## Property table | |
| 12 | + | ||
| 13 | + | Notation: `L` = nonnegative length (`px|%|em|rem|0`), `S` = signed length, `C` = color, `A` = `auto`, `N` = finite nonnegative number, `W` = border width `L`, `B` = border style `none|solid`. Four-value shorthands use CSS top/right/bottom/left order (1 = all, 2 = vertical/horizontal, 3 = top/horizontal/bottom, 4 = each). Every row states initial / inheritance / applicable elements; `all` means every NeferGUI element type. | |
| 14 | + | ||
| 15 | + | | Property | Value grammar | Initial | Inherited | Applies to | | |
| 16 | + | |---|---|---|---|---| | |
| 17 | + | | color | C | black | yes | all | | |
| 18 | + | | opacity | number 0..1 | 1 | no | all | | |
| 19 | + | | background-color | C | transparent | no | all | | |
| 20 | + | | background | C (resets background-color only) | transparent | no | all | | |
| 21 | + | | border-{top,right,bottom,left}-width | W | 0 | no | all | | |
| 22 | + | | border-{top,right,bottom,left}-style | B | none | no | all | | |
| 23 | + | | border-{top,right,bottom,left}-color | C | currentColor | no | all | | |
| 24 | + | | border-width / border-style / border-color | 1–4 W / B / C | per-side initials | no | all | | |
| 25 | + | | border-{top,right,bottom,left} | W? B? C? in any order (omitted fields reset to initials) | 0 none currentColor | no | all | | |
| 26 | + | | border | W? B? C? in any order (resets all sides) | 0 none currentColor | no | all | | |
| 27 | + | | border-{top-left,top-right,bottom-right,bottom-left}-radius | L (`%` relative to box) | 0 | no | all | | |
| 28 | + | | border-radius | 1–4 L clockwise; elliptical `/` radii unsupported | 0 | no | all | | |
| 29 | + | | outline-width / outline-style / outline-color | W / B / C | 0 / none / currentColor | no | all | | |
| 30 | + | | outline | W? B? C? (resets all three) | 0 none currentColor | no | all | | |
| 31 | + | | outline-offset | S (no %) | 0 | no | all | | |
| 32 | + | | box-shadow | none or comma list of `[inset]? S S L? S? C? [inset]?`; omitted blur/spread = 0, color = currentColor | none | no | all | | |
| 33 | + | | margin-{top,right,bottom,left} | S or A (%, em, rem allowed) | 0 | no | all | | |
| 34 | + | | margin | 1–4 margin-side values | 0 | no | all | | |
| 35 | + | | padding-{top,right,bottom,left} | L | 0 | no | all | | |
| 36 | + | | padding | 1–4 L | 0 | no | all | | |
| 37 | + | | width / height | L or A | auto | no | all | | |
| 38 | + | | min-width / min-height | L or A | auto | no | all | | |
| 39 | + | | max-width / max-height | L or A | auto | no | all | | |
| 40 | + | | box-sizing | content-box or border-box | content-box | no | all | | |
| 41 | + | | display | flex or block or none | block | no | all | | |
| 42 | + | | flex-direction | row or column | row | no | flex containers | | |
| 43 | + | | flex-wrap | nowrap or wrap | nowrap | no | flex containers | | |
| 44 | + | | flex-grow / flex-shrink | N | 0 / 1 | no | flex items | | |
| 45 | + | | flex-basis | L or A | auto | no | flex items | | |
| 46 | + | | flex | none (= 0 0 auto), auto (= 1 1 auto), or N [N]? [L|A]? (omitted shrink=1, basis=0%) | 0 1 auto | no | flex items | | |
| 47 | + | | gap / row-gap / column-gap | L (gap: 1 or 2 values row then column) | 0 | no | flex containers | | |
| 48 | + | | justify-content | flex-start, flex-end, center, space-between, space-around, space-evenly | flex-start | no | flex containers | | |
| 49 | + | | align-items / align-self | stretch, flex-start, flex-end, center (align-self also auto) | stretch / auto | no | flex containers / flex items | | |
| 50 | + | | overflow / overflow-x / overflow-y | visible, hidden, scroll, auto (shorthand 1 or 2 x then y) | visible | no | all | | |
| 51 | + | | font-family | comma list of quoted names or identifiers (multiword identifiers allowed); serif, sans-serif, monospace, cursive, fantasy, system-ui generic | sans-serif | yes | text and descendants | | |
| 52 | + | | font-size | L (percent relative to parent's font size) | 16px | yes | text and descendants | | |
| 53 | + | | font-weight | normal, bold, 100–900 in increments of 100 | 400 | yes | text and descendants | | |
| 54 | + | | font-style | normal, italic, oblique | normal | yes | text and descendants | | |
| 55 | + | | line-height | normal, nonnegative number, or L | normal | yes | text and descendants | | |
| 56 | + | | text-align | start, end, left, right, center | start | yes | text and descendants | | |
| 57 | + | | letter-spacing | normal or S (no %) | normal | yes | text and descendants | | |
| 58 | + | | cursor | auto, default, pointer, text, not-allowed | auto | yes | all | | |
| 59 | + | | accent-color | C | #3584e4 | yes | checkbox, radio and slider indicators | | |
| 60 | + | | --* | arbitrary token sequence, including var() | absent | yes | all | | |
| 61 | + | ||
| 62 | + | Only the listed properties and grammar are supported; e.g. transforms, transitions, grid, positioning, z-index, background images, font variants, viewport units, calc/min/max/clamp, and CSS-wide `revert` are unsupported. Unknown CSS is ignored with diagnostics as described above. Border radii affect painted boxes, but overflow clips use axis-aligned bounds rather than rounded clips. CSS `direction` is unsupported; sliders run left-to-right even when adjacent text is RTL. SVG and animated images are not decoded: only PNG and JPEG bytes are accepted, and other formats return a decode error. Text is grayscale, not LCD or SDF. Editors do not offer undo/redo, and no platform accessibility adapter is connected. The element types are `app`/`root`, `header`, `nav`, `main`, `section`, `aside`, `footer`, `button`, `input`, `textarea`, `checkbox`, `radio`, `slider`, `text`, `heading`, `image`, `icon`, `separator`, `spacer`, `row`, `column`, `stack`, `box`, `scroll`. UA: row/column use flex row/column; stack is a **distinct overlay layout** represented by type `stack` with block display (layout interprets its type; CSS display overrides layout); first child painted behind last, max child envelope, reverse hit order. Other elements start block. UA rules are overridable by author rules. | |
| 63 | + | ||
| 64 | + | ## Engine and caching | |
| 65 | + | ||
| 66 | + | `css.Compile(ua, author Sheet) *Engine` builds an indexed cascade. `css.UA()` supplies the embedded user-agent sheet. Stylesheet inputs are compiled/copy-referenced at construction; do not mutate parsed sheets while compiling. To change CSS, compile a new engine. Selectors are indexed by the rightmost compound's ID, first class, type, or the universal bucket. Matching walks compiled selector compounds and the parent chain of memo nodes, not a caller-supplied element parent. A matching selector-list entry contributes its own specificity; source order resolves ties. | |
| 67 | + | ||
| 68 | + | `css.ParseInline(src string) (*Declarations, []Diagnostic)` parses declarations (no braces). Pass the returned pointer as `Element.Inline`; treat it as immutable. `Element{Type, ID string; Classes []string; State StateFlags; Inline *Declarations}` is a per-call matching identity. State flags are `Hover`, `Active`, `Focus`, `FocusVisible`, `Disabled`. Classes are an unordered set; duplicate class entries are not expected. IDs/classes retain case, type is ASCII case-insensitive. | |
| 69 | + | ||
| 70 | + | `Engine.Compute(e *Element, parent *Computed) *Computed` takes the result of the same engine for the parent, or nil for the root. `Computed.Style` points to the typed `Style`, with fixed fields matching the property table (sides, corners, `Length`, straight sRGB `Color`, keyword enums, numeric fields, font-family and shadow slices). `LineHeightKind` distinguishes `normal` and unitless `number` (`LineHeightNumber`) from length (`LineHeight`); `LetterSpacingNormal` distinguishes `normal` from zero. Length units are strings (`px`, `%`, `auto`); `em`/`rem` resolve to `px`. Never mutate returned styles or slices, the inline declarations, or CSS inputs; no compatibility string-map style exists. Custom properties are internal inherited token values; only declarations containing `var()` retain tokens for computed-time substitution. Shorthands containing `var()` share one pending substitution: after resolving variables the entire shorthand is expanded and validated before any of its longhand winners are computed. Invalid substitution resets every winning longhand of that shorthand to inherited or initial, rather than exposing partial results. | |
| 71 | + | ||
| 72 | + | Cascade priority for normal declarations: UA < author < inline; for `!important`: author < inline, all important declarations win over normal (UA has no important). Within the same origin/importance, specificity then declaration source order wins. Invalid static declarations are dropped while compiling; invalid values after variable substitution invalidate the winning declaration at computed-value time, not the next cascade candidate. | |
| 73 | + | ||
| 74 | + | The engine interns strings and unordered class sets per engine. A memo key is `(parent node pointer, type, ID, class-set ID, StateFlags, inline pointer)`; a memo hit returns the exact same `*Computed` without allocating. Parents' matching identities live on memo nodes. `EndFrame()` advances the generation and evicts entries not used in the last two frames, retaining ancestors needed by recently used descendants. Call it once after each frame, including frames without CSS changes. This engine is **single-goroutine only** and does not use locks: do not call `Compute` or `EndFrame` concurrently. The caller must retain the parent `Computed` through computation of children; a changed ancestor identity or inline pointer creates a new memo path for that subtree. | |
| 75 | + | ||
| 76 | + | ### Performance and verification | |
| 77 | + | ||
| 78 | + | Engine benchmark numbers and budgets are in [performance.md](performance.md). Cold compute runs on first use or after a stylesheet change; a warm frame allocates nothing. A changed state recomputes exactly the affected subtree: `TestChangedStateMissesOnlySubtree` compares memo identities against the changed subtree size. | |
A docs/harness.md +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | # Visual harness | |
| 2 | + | ||
| 3 | + | `go run ./cmd/nefergui-harness run --size 800x600 --scale 1.5 --layout fr --input script.txt --expect expect.json --out artifacts -- <client> [args...]` | |
| 4 | + | ||
| 5 | + | Omit `-- <client>` for a compositor-only run. The output directory must not exist. `--background '#336699'` sets the compositor background (default `#111111`). `--timeout 15s` limits the whole run, including the build (which is separately capped at three minutes); `--ready-timeout 8s` bounds socket discovery. `--keep` preserves the isolated runtime/config directory and records its location in `result.json`. | |
| 6 | + | ||
| 7 | + | The harness installs NeferWL from pinned commit `1d16b83bf90f9e8189ad21b27772382c96b9f9a8` into `$XDG_CACHE_HOME/nefergui/harness/neferwl-<sha>/` on first use. `NEFERGUI_NEFERWL` selects an existing executable instead, but requires `--allow-unpinned`; this records `compositor.pinned=false` and `compositor.binary` in `result.json`. Pinned runs record `compositor.pinned=true` and `compositor.sha`. Installation uses `GOWORK=off`, a remote module, and no local replacement. A host Vulkan GPU and working NeferWL runtime are needed for integration. | |
| 8 | + | ||
| 9 | + | The session provides a private `XDG_RUNTIME_DIR`, NeferWL config, and discovered `WAYLAND_DISPLAY`; the compositor also gets a private `XDG_STATE_HOME`. The config enables NeferWL `wayland` debug logging; its structured run log is copied to `logs/compositor-run.log` and recorded as `artifacts.compositor_run_log` when NeferWL wrote one (a failed copy fails the run), where each client cursor shape change appears as a `cursor` entry. Host `DISPLAY`, `WAYLAND_DISPLAY`, and `WAYLAND_SOCKET` are removed from compositor and client environments. Clients also receive `NEFERGUI_DEBUG_DIR`. The harness only creates this directory; clients own their debug traces. To check a mapped surface before client shutdown, a client atomically publishes `client-debug/capture-request` (write a temporary file and rename) containing its wall-clock UnixNano immediately after committing its chosen frame, then remains mapped for at least two more compositor frames. NeferWL writes screenshots sequentially, so the first one finished after the timestamp may have been rendered before it; the harness selects the **second** `frame-NNNNNN.png` with modification time after that timestamp (retrying until it decodes completely), copies it to `frames/capture.png`, records `artifacts.capture`, and evaluates expectations against that capture. Without a request, expectations still use `frames/latest.png` (including compositor-only runs). A request without a later screenshot fails within the run timeout. Frames are in `frames/`, client debug files in `client-debug/`, logs in `logs/`, and machine-readable `result.json` uses schema `nefergui-harness/v1`. Run-specific paths and elapsed build/ready/client/total times are recorded. Temporary session directories disappear by default; artifacts remain. | |
| 10 | + | ||
| 11 | + | Example `expect.json`: | |
| 12 | + | ||
| 13 | + | ```json | |
| 14 | + | { | |
| 15 | + | "probes": [{"name":"background", "x":40, "y":40, "color":"#336699", "tolerance":1}], | |
| 16 | + | "regions": [{"name":"swatch", "rect":{"x":10,"y":10,"width":30,"height":20}, "color":"#336699", "tolerance":1}], | |
| 17 | + | "goldens": [{"name":"screen", "path":"reference.png", "tolerance":2, "max_diff_ratio":0.001}] | |
| 18 | + | } | |
| 19 | + | ``` | |
| 20 | + | ||
| 21 | + | Colors are `#RRGGBB`; tolerance is an inclusive per-channel 8-bit delta including alpha. Rectangles use half-open pixel coordinates. A region without `color` checks bounds only. Golden paths are relative to the expectations file; bounds must match. Each golden emits `diff-N.png` (absolute RGB channel deltas with alpha delta shown in red, black where within tolerance). `max_diff_ratio` is the fraction of pixels with **any** channel outside tolerance. GPU sRGB conversion can differ from the configured background by one channel value, so background tests allow tolerance 1. | |
| 22 | + | ||
| 23 | + | Exit codes: 0 all checks pass, 1 an assertion fails, 2 invalid usage, 3 NeferWL/build/runtime unavailable, 4 client or compositor fails after readiness or fails to produce a frame. Run the opt-in compositor smoke test with `NEFERGUI_HARNESS=1 GOWORK=off go test -v ./internal/harness -run TestHeadlessBackground -count=1`. | |
A docs/layout.md +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | # Logical layout | |
| 2 | + | ||
| 3 | + | `internal/layout.Layout(root, Options{Width, Height, TextEngine})` accepts a transient tree of `Node{ID, Kind, Style, Children, Content, ImageSize, Measure, ScrollX, ScrollY}`. Styles are borrowed immutable `*css.Style` values from `css.Engine.Compute` (normally compiled with `css.UA()`). A nil style receives minimal block defaults; use computed CSS for complete visual defaults. IDs are supplied by the frame-tree adapter and should be unique. Input must be acyclic, with depth at most 256; no renderer resources or parent pointers are retained. The caller owns the decoded images: use `internal/image.Decode` for bounded PNG/JPEG bytes or `internal/image.Size` for existing Go images, then put the intrinsic size on the node. Byte input must itself be bounded by the reader before decoding. | |
| 4 | + | ||
| 5 | + | `Kind` differentiates image/text intrinsic measurement, Stack overlay, and Scroll clipping. Flex is selected by the computed `display:flex`, with row/column UA defaults overridable by author CSS. Block children flow vertically; Stack children share the origin, paint in input order, and hit-test in reverse order. Flex supports row/column, wrap, grow/shrink/basis, row/column gaps, align-self/items and justify-content. CSS `px` is logical; computed `em`/`rem` have already become `px`; `%` dimensions use the containing content box and padding/margins use containing width. Auto width fills the containing width except images; auto height fits the children. Oversized/negative/nonfinite geometry is clamped to bounded logical coordinates. CSS `display:none` drops a subtree. Overflow hidden/auto/scroll emits a content clip per axis, leaving visible axes unbounded; only scroll/auto (or Scroll kind) consumes offsets, clamped to the measured content extent. The caller supplies offset; there is no scroll event policy here. | |
| 6 | + | ||
| 7 | + | `Output.Tree` is JSON-safe geometry and measured content extent. `Output.Display` contains ordered JSON-safe commands (`shadow`, `rect`, `border`, `outline`, `clip-push`, `text`, `image`, `clip-pop`). Glyph rune-cluster offsets and shaped glyph positions are copied to text commands. Each display-list `Run` also borrows its shaped `*text.Face` for in-process rasterization and carries serializable `FaceID` and `Size` (the logical font size used for shaping). `Face` is omitted from JSON; decoding JSON alone cannot reconstruct a face, so a renderer consuming serialized commands must resolve `FaceID` to a matching font before rasterizing. The original `text.Line` values remain on tree nodes for in-process use and are omitted from JSON. Image commands reference node IDs; the renderer adapter resolves their pixels. `Hit` applies ancestor clips and reverse child order. Pointer capture, focus and tab ordering are separate runtime responsibilities. `Physical` independently rounds logical rectangle edges using a fractional Wayland scale at the renderer boundary; do not use physical coordinates for hit tests. JSON snapshots under `internal/layout/testdata` validate tree and list structure, not rendered pixels. | |
| 8 | + | ||
| 9 | + | Known bounds: layout does not implement CSS margin collapse, absolute positioning, baseline alignment, `align-content`, or CSS transforms. Shadow and border commands carry geometry/style data for the renderer; decoration rasterization is not owned here. Text requires a provided `text.Engine` or a synchronous measure callback. Computed line-height repositions shaped baselines, text-align offsets runs within each content line, and letter-spacing is applied to shaped clusters before the text engine wraps (via the additive `text.Request.LetterSpacing` option). A synchronous callback's result must be immutable for the duration of layout; do not share a mutable shaping engine across goroutines. | |
A docs/performance.md +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | # CPU performance budgets | |
| 2 | + | ||
| 3 | + | AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission. | |
| 4 | + | ||
| 5 | + | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | | |
| 6 | + | |---|---:|---:|---:|---:|---:|---:| | |
| 7 | + | | root / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 | | |
| 8 | + | | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 | | |
| 9 | + | | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 | | |
| 10 | + | | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 | | |
| 11 | + | | text / ShapeParagraph (uncached) | 226,223 | 300,146 | 950 | 460,000 | 600,000 | 1,900 | | |
| 12 | + | | text / ShapeParagraphCached | 78.16 | 0 | 0 | 160 | 0 | 0 | | |
| 13 | + | | text / AtlasInsert | 508.3 | 966 | 3 | 1,050 | 2,000 | 6 | | |
| 14 | + | | css / Engine/cold | 2,310,093 | 1,956,258 | 5,608 | 4,700,000 | 4,000,000 | 11,300 | | |
| 15 | + | | css / Engine/warm | 95,553 | 1 | 0 | 195,000 | 2 | 0 | | |
| 16 | + | | css / Engine/state-change | 334,293 | 149,130 | 363 | 680,000 | 300,000 | 750 | | |
| 17 | + | | layout / Layout1000 | 623,154 | 731,626 | 1,027 | 1,250,000 | 1,470,000 | 2,100 | | |
| 18 | + | ||
| 19 | + | `TestDemoFrameAllocationBudget` checks the 500 allocs/op limit with `testing.AllocsPerRun`. | |
| 20 | + | ||
| 21 | + | Frame construction stays low-allocation because: | |
| 22 | + | ||
| 23 | + | - font family names are normalized once when fonts load, not per face on every match; | |
| 24 | + | - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions); | |
| 25 | + | - identity path keys are computed once per element. | |
| 26 | + | ||
| 27 | + | `Run` builds a frame only on input or a redraw request, so an idle window allocates nothing. Profile with: | |
| 28 | + | ||
| 29 | + | ```sh | |
| 30 | + | go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 . | |
| 31 | + | go tool pprof -sample_index=alloc_objects -top -cum mem.out | |
| 32 | + | ``` | |
| 33 | + | ||
| 34 | + | A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load. | |
A docs/runtime.md +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | # Immediate runtime | |
| 2 | + | ||
| 3 | + | `Run(ctx, &model, view, options...)` opens a Wayland toplevel, renders with Vulkan and returns when the window closes (`nil`) or `ctx` is cancelled (`ctx.Err()`). Options: `Title`, `Size` (logical pixels), `Styles` (CSS file path; a read error is returned) and `Transparent` (windows are opaque by default). Setup failures (no fonts, no Wayland display, no usable Vulkan device) are returned as errors. Accessibility is not connected. | |
| 4 | + | ||
| 5 | + | The UI loop never blocks on the GPU or the compositor. It applies Wayland events, drains seat input, fires key repeat from a timer, and builds `view` once per input batch or redraw request. A frame is prepared and presented only when the build or the window size/scale changed. Clipboard reads are asynchronous: paste starts a read and the text is inserted by the next frame. A new paste cancels the previous one. Input-method (text-input v3) batches are applied during frame construction. | |
| 6 | + | ||
| 7 | + | The unexported `runtime` constructs views synchronously. A concurrent or re-entrant build never waits: it requests a redraw, posts a wake token and returns false. The in-progress frame commits first; queued input remains for the next build. | |
| 8 | + | ||
| 9 | + | The unexported UI-loop port `platformInput` accepts logical pointer motion/press/release/axis/leave, interpreted `keyboard.Key` events, focus-in/out and logical viewport resize/scale. `route` hit-tests against the last committed layout (reverse paint order and ancestor clips), resolves the result to a frame identity and wakes the next build only when state changes. `Target` is a committed-child-path helper for tests. A build computes CSS pseudo-state and commits layout, display list and tree together. The headless viewport defaults to 800×600 logical pixels; `Run` sets it from the window. Scale is used by the renderer only and never affects hit testing: Wayland surface coordinates are already logical. A seat input-queue overflow is delivered as a reset (pointer leave, all buttons released, keyboard focus-out). `Run` installs a text engine from system fonts; headless tests install one explicitly, and without one text commands have no measured glyph runs. | |
| 10 | + | ||
| 11 | + | ## Example | |
| 12 | + | ||
| 13 | + | `examples/demo` is the product example with an embedded stylesheet: | |
| 14 | + | ||
| 15 | + | ```sh | |
| 16 | + | GOWORK=off go run ./examples/demo | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | `--static --frames N` renders N frames and exits (used by the visual harness). | |
| 20 | + | ||
| 21 | + | Tab traverses visible, enabled controls in document order and wraps; Shift+Tab reverses. Keyboard focus sets `:focus-visible`; pointer focus does not. Enter activates focused buttons and checkboxes on initial key press, ignoring repeats. Space sets `:active` on initial press and activates on release only if focus is unchanged; focus loss/change or Escape cancels it, and repeats are ignored. Disabled controls cannot focus or activate. When a focused control is removed or disabled, focus falls back to **none** (the next Tab starts at the first available control). The first pressed pointer button owns capture until its matching release, even outside the target; other button presses/releases cannot steal it or change focus. Only primary-button release on the captured control activates it. Pointer leave clears hover but preserves capture until matching release (an out-of-surface release cannot activate); removal or platform focus loss cancels capture. Scroll wheel deltas apply to the nearest scrollable ancestor and clamp to measured content bounds; offsets are discarded when that identity is no longer a laid-out scroll container. Unchanged state does not redraw. No user callback runs outside frame construction. | |
| 22 | + | ||
| 23 | + | Offline keyboard tests require libxkbcommon.so.0 and a usable XKB data installation; a host without those prerequisites may opt out with `NEFERGUI_SKIP_XKB=1` (other test failures still fail). The valid-FD test fixture is a French Wayland keymap generated with `xkbcli compile-keymap --layout fr --output-format 1` from libxkbcommon 1.13.2; SHA-256 `f6bcde4e4e03293d05c9426d08cdc2e9a78e7b8faef39dc97c5d267ad7355607`. | |
| 24 | + | ||
| 25 | + | Compile with `-tags nefergui_debug` to report duplicate sibling keys, changed unkeyed child types and stale Node use (stale use panics). Without this tag, stale handles are inert. Do not retain a Node across frames. `ID` is only for CSS, while `Key` identifies an element within its parent. Unkeyed children are identified by their position and type. | |
| 26 | + | ||
| 27 | + | System font discovery indexes family and aspect from font metadata without retaining font files in memory. Text measurement loads and caches only selected faces; CSS family requests and script fallback select faces on demand. User font directories precede system directories, with sorted paths breaking ties. Fonts that fail to load are skipped and reported in catalog diagnostics. | |
A docs/troubleshooting.md +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | # Troubleshooting | |
| 2 | + | ||
| 3 | + | - No display: `nefergui: open platform: ...` wraps the Wayland connection error. Check `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR`, and that a Wayland compositor is running. | |
| 4 | + | - Missing protocol: `Wayland capability <interface>: ...` identifies the required compositor global. NeferGUI needs linux-dmabuf and linux-drm-syncobj; switch to a compositor offering both. | |
| 5 | + | - No suitable GPU: `no Vulkan physical devices; need DRM main_device ...`, `no Vulkan DRM device matches linux-dmabuf main_device ...`, or `matching Vulkan device missing ...` means the Vulkan driver cannot serve the compositor's DRM device. `no one-plane exportable B8G8R8A8 modifier shared with compositor ...` means they have no compatible modifier. Check driver and compositor support; there is no cross-GPU fallback. | |
| 6 | + | - Fonts: `nefergui: fonts: no usable fonts: ...` or `nefergui: no usable fonts` means system font discovery failed. Install usable TrueType/OpenType system fonts. | |
| 7 | + | - `NEFERGUI_VULKAN_VALIDATION=1` enables Vulkan validation; an unavailable validation layer reports `validation layer VK_LAYER_KHRONOS_validation unavailable`. Install the validation layer or unset the variable. Broken implicit Vulkan layers can prevent startup: use `VK_LOADER_LAYERS_DISABLE=<layer>` to disable the offending layer for diagnosis. | |
| 8 | + | - `NEFERGUI_DEBUG_DIR` writes diagnostic artifacts, including `frame-timings.jsonl` and `buffer-lifecycle.jsonl`; choose a writable directory. Build with `-tags nefergui_debug` for identity diagnostics (duplicate keys, unkeyed type changes, stale handles). | |
| 9 | + | - To regenerate committed SPIR-V, install `glslc` and run `go generate ./...`; consumers do not need `glslc`. | |
| 10 | + | - If a requested font cannot be opened or parsed, text selection skips that face and tries the next matching face. Inspect catalog diagnostics for the failed path; system font discovery uses XDG font directories and does not require fontconfig. | |
A edit_geometry.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "sort" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefergui/internal/edit" | |
| 9 | + | "github.com/bnema/nefergui/internal/layout" | |
| 10 | + | "github.com/go-text/typesetting/di" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type textBoundary struct { | |
| 14 | + | byteOffset int | |
| 15 | + | x, y float64 | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // textBoundaries projects editor byte boundaries onto laid-out glyph clusters. | |
| 19 | + | // Runs are in visual order; glyph cluster indices are source rune offsets. | |
| 20 | + | func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary { | |
| 21 | + | if n == nil { | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | offsets := edit.Boundaries(value) | |
| 25 | + | runeAt := make(map[int]int, len(offsets)) | |
| 26 | + | for _, b := range offsets { | |
| 27 | + | runeAt[utf8.RuneCountInString(value[:b])] = b | |
| 28 | + | } | |
| 29 | + | var runs []layout.Run | |
| 30 | + | for _, cmd := range display { | |
| 31 | + | if cmd.ID == n.ID && cmd.Op == "text" { | |
| 32 | + | runs = cmd.Runs | |
| 33 | + | break | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | var lines [][]textBoundary | |
| 37 | + | ri := 0 | |
| 38 | + | top := n.Content.Y | |
| 39 | + | for _, line := range n.Lines { | |
| 40 | + | current := []textBoundary{} | |
| 41 | + | for _, shaped := range line.Runs { | |
| 42 | + | if ri >= len(runs) { | |
| 43 | + | break | |
| 44 | + | } | |
| 45 | + | run := runs[ri] | |
| 46 | + | ri++ | |
| 47 | + | // Cluster start and end positions are derived from the glyph ink advances, | |
| 48 | + | // not rune counts (ligatures and combining graphemes have one visual span). | |
| 49 | + | type span struct{ low, high float64 } | |
| 50 | + | spans := make(map[int]span) | |
| 51 | + | for _, g := range run.Glyphs { | |
| 52 | + | p, ok := spans[g.Cluster] | |
| 53 | + | if !ok { | |
| 54 | + | p = span{g.X, g.X + g.Advance} | |
| 55 | + | } else { | |
| 56 | + | p.low = math.Min(p.low, g.X) | |
| 57 | + | p.high = math.Max(p.high, g.X+g.Advance) | |
| 58 | + | } | |
| 59 | + | spans[g.Cluster] = p | |
| 60 | + | } | |
| 61 | + | starts := make([]int, 0, len(spans)) | |
| 62 | + | for k := range spans { | |
| 63 | + | starts = append(starts, k) | |
| 64 | + | } | |
| 65 | + | sort.Ints(starts) | |
| 66 | + | for i, k := range starts { | |
| 67 | + | end := shaped.End | |
| 68 | + | if i+1 < len(starts) { | |
| 69 | + | end = starts[i+1] | |
| 70 | + | } | |
| 71 | + | p := spans[k] | |
| 72 | + | a, b := p.low, p.high | |
| 73 | + | if shaped.Direction == di.DirectionRTL { | |
| 74 | + | a, b = b, a | |
| 75 | + | } | |
| 76 | + | if offset, ok := runeAt[k]; ok { | |
| 77 | + | current = append(current, textBoundary{offset, a, top}) | |
| 78 | + | } | |
| 79 | + | if offset, ok := runeAt[end]; ok { | |
| 80 | + | current = append(current, textBoundary{offset, b, top}) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if len(current) == 0 { // empty paragraph, including a trailing newline | |
| 85 | + | offset := 0 | |
| 86 | + | if len(lines) > 0 && len(lines[len(lines)-1]) > 0 { | |
| 87 | + | for _, boundary := range lines[len(lines)-1] { | |
| 88 | + | if boundary.byteOffset > offset { | |
| 89 | + | offset = boundary.byteOffset | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | for offset < len(value) && value[offset] == '\n' { | |
| 93 | + | offset++ | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | current = append(current, textBoundary{offset, n.Content.X, top}) | |
| 97 | + | } | |
| 98 | + | lines = append(lines, current) | |
| 99 | + | top += line.Height | |
| 100 | + | } | |
| 101 | + | return lines | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func nearestTextBoundary(value string, n *layout.Result, display []layout.Command, x, y float64) int { | |
| 105 | + | lines := textBoundaries(value, n, display) | |
| 106 | + | if len(lines) == 0 { | |
| 107 | + | return 0 | |
| 108 | + | } | |
| 109 | + | line := 0 | |
| 110 | + | top := n.Content.Y | |
| 111 | + | for i, shaped := range n.Lines { | |
| 112 | + | if y >= top+shaped.Height/2 { | |
| 113 | + | line = i | |
| 114 | + | } | |
| 115 | + | top += shaped.Height | |
| 116 | + | } | |
| 117 | + | if line >= len(lines) { | |
| 118 | + | line = len(lines) - 1 | |
| 119 | + | } | |
| 120 | + | best := 0 | |
| 121 | + | distance := math.Inf(1) | |
| 122 | + | for _, b := range lines[line] { | |
| 123 | + | if d := math.Abs(b.x - x); d < distance { | |
| 124 | + | distance = d | |
| 125 | + | best = b.byteOffset | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return best | |
| 129 | + | } | |
A edit_mixed_direction_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |
| 9 | + | "github.com/bnema/nefergui/internal/text" | |
| 10 | + | "github.com/go-text/typesetting/di" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestMixedDirectionSelectionVisualRects(t *testing.T) { | |
| 14 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | r := newRuntime() | |
| 19 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 20 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {width:300px;height:28px;font-size:16px}`)) | |
| 21 | + | value := "abc مرحبا def" | |
| 22 | + | view := func(f *Frame) { f.Root().Input("mixed", &value) } | |
| 23 | + | r.Build(view) | |
| 24 | + | r.route(key("Tab")) | |
| 25 | + | r.Build(view) | |
| 26 | + | n := r.output.Tree.Children[0] | |
| 27 | + | if len(n.Lines) != 1 { | |
| 28 | + | t.Fatalf("expected one line, got %d", len(n.Lines)) | |
| 29 | + | } | |
| 30 | + | rtl := false | |
| 31 | + | ltr := false | |
| 32 | + | for _, run := range n.Lines[0].Runs { | |
| 33 | + | rtl = rtl || run.Direction == di.DirectionRTL | |
| 34 | + | ltr = ltr || run.Direction == di.DirectionLTR | |
| 35 | + | } | |
| 36 | + | if !rtl || !ltr { | |
| 37 | + | t.Fatalf("not mixed bidi: %+v", n.Lines[0].Runs) | |
| 38 | + | } | |
| 39 | + | s := r.edits[n.ID] | |
| 40 | + | s.Select(len("abc "), len("abc مرحبا ")) | |
| 41 | + | r.Redraw() | |
| 42 | + | r.Build(view) | |
| 43 | + | n = r.output.Tree.Children[0] | |
| 44 | + | boundaries := textBoundaries(value, n, r.output.Display) | |
| 45 | + | if len(boundaries) != 1 { | |
| 46 | + | t.Fatalf("expected one visual line: %+v", boundaries) | |
| 47 | + | } | |
| 48 | + | // Selected logical clusters must paint at the corresponding visual span, | |
| 49 | + | // including the reversed RTL run and the following LTR separator. | |
| 50 | + | var rects []layout.Rect | |
| 51 | + | for _, c := range r.output.Display { | |
| 52 | + | if c.ID == n.ID && c.Op == "selection" { | |
| 53 | + | rects = append(rects, c.Rect) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if len(rects) < 6 { | |
| 57 | + | t.Fatalf("missing per-cluster bidi highlights: %+v", rects) | |
| 58 | + | } | |
| 59 | + | for _, b := range []int{len("abc "), len("abc م"), len("abc مر"), len("abc مرح"), len("abc مرحب")} { | |
| 60 | + | has := false | |
| 61 | + | for _, edge := range boundaries[0] { | |
| 62 | + | if edge.byteOffset != b { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | for _, rect := range rects { | |
| 66 | + | if math.Abs(rect.X-edge.x) < 0.01 || math.Abs(rect.X+rect.W-edge.x) < 0.01 { | |
| 67 | + | has = true | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if !has { | |
| 72 | + | t.Fatalf("byte edge %d not painted at visual cluster: %+v rects=%+v", b, boundaries[0], rects) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
A edit_navigation.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/edit" | |
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // moveVisualLine uses the last committed wrap geometry; preferred X survives | |
| 11 | + | // consecutive vertical moves, but is reset by horizontal editing and pointer input. | |
| 12 | + | func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, down, shift bool) { | |
| 13 | + | lines := textBoundaries(s.Value, n, display) | |
| 14 | + | if len(lines) == 0 { | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | current := 0 | |
| 18 | + | for i, line := range lines { | |
| 19 | + | for _, b := range line { | |
| 20 | + | if b.byteOffset == s.Cursor { | |
| 21 | + | current = i | |
| 22 | + | break | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | // Prefer the following line at a shared wrap boundary. | |
| 26 | + | } | |
| 27 | + | if !s.PreferredXValid { | |
| 28 | + | for _, b := range lines[current] { | |
| 29 | + | if b.byteOffset == s.Cursor { | |
| 30 | + | s.PreferredX = b.x | |
| 31 | + | break | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | s.PreferredXValid = true | |
| 35 | + | } | |
| 36 | + | next := current - 1 | |
| 37 | + | if down { | |
| 38 | + | next = current + 1 | |
| 39 | + | } | |
| 40 | + | if next < 0 || next >= len(lines) { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | best := s.Cursor | |
| 44 | + | distance := math.Inf(1) | |
| 45 | + | for _, b := range lines[next] { | |
| 46 | + | if down && b.byteOffset <= s.Cursor { | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | if !down && b.byteOffset >= s.Cursor { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | if d := math.Abs(b.x - s.PreferredX); d < distance { | |
| 53 | + | distance = d | |
| 54 | + | best = b.byteOffset | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | s.Move(best, shift) | |
| 58 | + | } | |
A edit_navigation_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/nefergui/internal/css" | |
| 5 | + | "github.com/bnema/nefergui/internal/text" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestWrappedNavigationAndScroll(t *testing.T) { | |
| 10 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 11 | + | if err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | r := newRuntime() | |
| 15 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 16 | + | r.styles = css.Compile(css.UA(), css.Parse(`textarea {width: 45px; height: 20px; font-size:16px} input {width: 30px; height:20px; font-size:16px}`)) | |
| 17 | + | value := "one two three four five six" | |
| 18 | + | single := "abcdefghijk" | |
| 19 | + | view := func(f *Frame) { | |
| 20 | + | root := f.Root() | |
| 21 | + | root.Textarea("multiline", &value, Key("multi")) | |
| 22 | + | root.Input("single", &single, Key("single")) | |
| 23 | + | } | |
| 24 | + | r.Build(view) | |
| 25 | + | n := r.output.Tree.Children[0] | |
| 26 | + | id := n.ID | |
| 27 | + | if len(n.Lines) < 3 { | |
| 28 | + | t.Fatalf("expected wraps, got %d", len(n.Lines)) | |
| 29 | + | } | |
| 30 | + | r.route(key("Tab")) | |
| 31 | + | r.Build(view) | |
| 32 | + | s := r.edits[id] | |
| 33 | + | s.Move(0, false) | |
| 34 | + | r.Redraw() | |
| 35 | + | r.Build(view) | |
| 36 | + | r.route(key("Down")) | |
| 37 | + | r.Build(view) | |
| 38 | + | first := s.Cursor | |
| 39 | + | if first == 0 { | |
| 40 | + | t.Fatal("down did not move to wrapped line") | |
| 41 | + | } | |
| 42 | + | r.route(key("Down")) | |
| 43 | + | r.Build(view) | |
| 44 | + | if s.Cursor <= first || !s.PreferredXValid { | |
| 45 | + | t.Fatalf("second wrapped down: %d then %d", first, s.Cursor) | |
| 46 | + | } | |
| 47 | + | if r.output.Tree.Children[0].ScrollY <= 0 { | |
| 48 | + | t.Fatal("caret did not scroll textarea vertically") | |
| 49 | + | } | |
| 50 | + | r.route(key("Up")) | |
| 51 | + | r.Build(view) | |
| 52 | + | if s.Cursor != first { | |
| 53 | + | t.Fatalf("preferred-x return got %d want %d", s.Cursor, first) | |
| 54 | + | } | |
| 55 | + | r.route(key("Tab")) | |
| 56 | + | r.Build(view) | |
| 57 | + | singleNode := r.output.Tree.Children[1] | |
| 58 | + | if len(singleNode.Lines) != 1 { | |
| 59 | + | t.Fatalf("input wrapped: %d", len(singleNode.Lines)) | |
| 60 | + | } | |
| 61 | + | if singleNode.ScrollX <= 0 || singleNode.ScrollY != 0 { | |
| 62 | + | t.Fatalf("input scroll %+v", singleNode) | |
| 63 | + | } | |
| 64 | + | } | |
A edit_paint.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/edit" | |
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var selectionColor = css.Color{R: 0.22, G: 0.43, B: 0.85, A: 0.4} | |
| 12 | + | var caretColor = css.Color{R: 0, G: 0, B: 0, A: 1} | |
| 13 | + | ||
| 14 | + | func (r *runtime) paintEditors(root *element, out *layout.Output) { | |
| 15 | + | var visit func(*element) | |
| 16 | + | visit = func(e *element) { | |
| 17 | + | if e == nil { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | if e.typ == "input" || e.typ == "textarea" { | |
| 21 | + | id := idKey(e.identity) | |
| 22 | + | s := r.edits[id] | |
| 23 | + | n := resultByID(out.Tree, id) | |
| 24 | + | if s != nil && n != nil { | |
| 25 | + | var commands []layout.Command | |
| 26 | + | if s.Value == "" && s.Preedit == "" && e.placeholder != "" && r.textEngine != nil { | |
| 27 | + | commands = placeholderCommand(e, n, r.textEngine) | |
| 28 | + | } | |
| 29 | + | if !e.disabled { | |
| 30 | + | commands = append(commands, editorDecoration(s, n, out.Display, e.password, e.identity.same(r.state.focus))...) | |
| 31 | + | if s.Preedit != "" && e.identity.same(r.state.focus) && r.textEngine != nil { | |
| 32 | + | commands = append(commands, preeditCommands(s, n, e, r.textEngine, out.Display)...) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | insertClippedCommands(out, id, commands) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | for _, child := range e.children { | |
| 39 | + | visit(child) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | visit(root) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // editorDecoration maps logical grapheme selection to visual cluster spans. | |
| 46 | + | // A bidi boundary uses the run containing the character before the cursor: | |
| 47 | + | // later duplicate offsets must not override the first span's trailing edge. | |
| 48 | + | func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command { | |
| 49 | + | if n == nil { | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | value := s.Value | |
| 53 | + | start, end := s.Range() | |
| 54 | + | cursor := s.Cursor | |
| 55 | + | if password { | |
| 56 | + | boundaries := edit.Boundaries(value) | |
| 57 | + | value = s.Mask() | |
| 58 | + | render := edit.Boundaries(value) | |
| 59 | + | for i, b := range boundaries { | |
| 60 | + | if b == start { | |
| 61 | + | start = render[i] | |
| 62 | + | } | |
| 63 | + | if b == end { | |
| 64 | + | end = render[i] | |
| 65 | + | } | |
| 66 | + | if b == cursor { | |
| 67 | + | cursor = render[i] | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | lines := textBoundaries(value, n, display) | |
| 72 | + | var result []layout.Command | |
| 73 | + | for i, line := range lines { | |
| 74 | + | h := 0.0 | |
| 75 | + | if i < len(n.Lines) { | |
| 76 | + | h = n.Lines[i].Height | |
| 77 | + | } | |
| 78 | + | for j := 0; j+1 < len(line); j += 2 { | |
| 79 | + | a, b := line[j], line[j+1] | |
| 80 | + | if a.byteOffset == b.byteOffset { | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | low, high := a.byteOffset, b.byteOffset | |
| 84 | + | if low > high { | |
| 85 | + | low, high = high, low | |
| 86 | + | } | |
| 87 | + | if start < high && end > low && start != end { | |
| 88 | + | x := math.Min(a.x, b.x) | |
| 89 | + | w := math.Abs(a.x - b.x) | |
| 90 | + | if w > 0 { | |
| 91 | + | result = append(result, layout.Command{Op: "selection", ID: n.ID, Rect: layout.Rect{X: x, Y: a.y, W: w, H: h}, Color: selectionColor, Opacity: 1}) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if focused { | |
| 97 | + | var point *textBoundary | |
| 98 | + | // The trailing edge of the preceding character takes priority over the | |
| 99 | + | // leading edge of the following run when both map to the same offset. | |
| 100 | + | for i := range lines { | |
| 101 | + | for j := range lines[i] { | |
| 102 | + | b := &lines[i][j] | |
| 103 | + | if b.byteOffset == cursor && point == nil { | |
| 104 | + | point = b | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if point == nil && len(lines) == 0 { | |
| 109 | + | result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1}) | |
| 110 | + | } | |
| 111 | + | if point != nil { | |
| 112 | + | h := n.Content.H | |
| 113 | + | for i, line := range lines { | |
| 114 | + | if len(line) > 0 && line[0].y == point.y && i < len(n.Lines) { | |
| 115 | + | h = n.Lines[i].Height | |
| 116 | + | break | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: h}, Color: caretColor, Opacity: 1}) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | return result | |
| 123 | + | } | |
A edit_paint_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/nefergui/internal/css" | |
| 5 | + | "github.com/bnema/nefergui/internal/text" | |
| 6 | + | "math" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestEditorVisualSelectionAndCaret(t *testing.T) { | |
| 11 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | for _, tc := range []struct { | |
| 16 | + | name, value string | |
| 17 | + | start, end int | |
| 18 | + | }{{"latin", "abc def", 1, 5}, {"arabic", "مرحبا", 0, len("مرحبا")}} { | |
| 19 | + | t.Run(tc.name, func(t *testing.T) { | |
| 20 | + | r := newRuntime() | |
| 21 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 22 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {width:240px;height:24px;font-size:16px}`)) | |
| 23 | + | value := tc.value | |
| 24 | + | view := func(f *Frame) { f.Root().Input("label", &value) } | |
| 25 | + | r.Build(view) | |
| 26 | + | r.route(key("Tab")) | |
| 27 | + | r.Build(view) | |
| 28 | + | s := r.edits[idKey(r.Target(0))] | |
| 29 | + | s.Select(tc.start, tc.end) | |
| 30 | + | r.Redraw() | |
| 31 | + | r.Build(view) | |
| 32 | + | count := 0 | |
| 33 | + | minX, maxX := math.Inf(1), math.Inf(-1) | |
| 34 | + | caret := 0 | |
| 35 | + | for _, c := range r.output.Display { | |
| 36 | + | if c.ID != idKey(r.Target(0)) { | |
| 37 | + | continue | |
| 38 | + | } | |
| 39 | + | if c.Op == "selection" { | |
| 40 | + | count++ | |
| 41 | + | minX = math.Min(minX, c.Rect.X) | |
| 42 | + | maxX = math.Max(maxX, c.Rect.X+c.Rect.W) | |
| 43 | + | if c.Rect.W <= 0 { | |
| 44 | + | t.Fatal("zero highlight") | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if c.Op == "caret" { | |
| 48 | + | caret++ | |
| 49 | + | if c.Rect.W != 1 { | |
| 50 | + | t.Fatalf("caret width: %+v", c.Rect) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | if count == 0 || caret != 1 || !(maxX > minX) { | |
| 55 | + | t.Fatalf("selection=%d caret=%d extent=%g:%g", count, caret, minX, maxX) | |
| 56 | + | } | |
| 57 | + | if tc.name == "arabic" { | |
| 58 | + | var x float64 | |
| 59 | + | for _, c := range r.output.Display { | |
| 60 | + | if c.Op == "caret" { | |
| 61 | + | x = c.Rect.X | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if math.Abs(x-minX) > 2 { | |
| 65 | + | t.Fatalf("RTL trailing caret at %g, highlight starts %g", x, minX) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
A edit_pointer_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPointerTextSelection(t *testing.T) { | |
| 12 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | r := newRuntime() | |
| 17 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 18 | + | r.styles = css.Compile(css.UA(), css.Parse(`input, textarea { width: 240px; height: 32px; font-size: 16px; }`)) | |
| 19 | + | value := "aé word" | |
| 20 | + | view := func(f *Frame) { f.Root().Input("edit", &value, Key("edit")) } | |
| 21 | + | r.Build(view) | |
| 22 | + | n := r.output.Tree.Children[0] | |
| 23 | + | id := n.ID | |
| 24 | + | bs := textBoundaries(value, n, r.output.Display) | |
| 25 | + | if len(bs) == 0 || len(bs[0]) < 3 { | |
| 26 | + | t.Fatalf("no text clusters: %+v", bs) | |
| 27 | + | } | |
| 28 | + | x := func(b int) float64 { | |
| 29 | + | for _, v := range bs[0] { | |
| 30 | + | if v.byteOffset == b { | |
| 31 | + | return v.x | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | t.Fatalf("no boundary %d: %+v", b, bs) | |
| 35 | + | return 0 | |
| 36 | + | } | |
| 37 | + | y := n.Content.Y + 1 | |
| 38 | + | at := time.Unix(20, 0) | |
| 39 | + | press := func(px float64, when time.Time, shift bool) { | |
| 40 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: px, Y: y, Time: when, Shift: shift}) | |
| 41 | + | r.Build(view) | |
| 42 | + | } | |
| 43 | + | release := func() { r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 0, Y: y}); r.Build(view) } | |
| 44 | + | press(x(3), at, false) | |
| 45 | + | if got := r.edits[id].Cursor; got != 3 { | |
| 46 | + | t.Fatalf("UTF-8 boundary got %d want 3: %+v content=%+v", got, bs, n.Content) | |
| 47 | + | } | |
| 48 | + | r.route(platformInput{Kind: "motion", X: x(0), Y: y}) | |
| 49 | + | r.Build(view) | |
| 50 | + | if a, b := r.edits[id].Range(); a != 0 || b != 3 { | |
| 51 | + | t.Fatalf("drag selection %d:%d", a, b) | |
| 52 | + | } | |
| 53 | + | release() | |
| 54 | + | press(x(8)-0.01, at.Add(time.Second), true) | |
| 55 | + | if a, b := r.edits[id].Range(); a != 3 || b != 8 { | |
| 56 | + | t.Fatalf("shift selection %d:%d", a, b) | |
| 57 | + | } | |
| 58 | + | release() | |
| 59 | + | press(x(5), at.Add(2*time.Second), false) | |
| 60 | + | release() | |
| 61 | + | press(x(5), at.Add(2*time.Second+300*time.Millisecond), false) | |
| 62 | + | if a, b := r.edits[id].Range(); a != 4 || b != 8 { | |
| 63 | + | t.Fatalf("double word selection %d:%d", a, b) | |
| 64 | + | } | |
| 65 | + | release() | |
| 66 | + | // A word start used to include the previous segment too. | |
| 67 | + | press(x(4), at.Add(3*time.Second), false) | |
| 68 | + | release() | |
| 69 | + | press(x(4), at.Add(3*time.Second+200*time.Millisecond), false) | |
| 70 | + | if a, b := r.edits[id].Range(); a != 4 || b != 8 { | |
| 71 | + | t.Fatalf("boundary word selection %d:%d", a, b) | |
| 72 | + | } | |
| 73 | + | } | |
A edit_preedit.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "unicode/utf8" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/edit" | |
| 9 | + | "github.com/bnema/nefergui/internal/layout" | |
| 10 | + | "github.com/bnema/nefergui/internal/text" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // shapeAt shapes s in the element's font and letter spacing, as layout | |
| 14 | + | // measures editor text, and places its runs from the logical point (x, y). | |
| 15 | + | // wrap > 0 breaks lines at that width. box > 0 aligns each line within a box | |
| 16 | + | // of that width by text-align; zero keeps lines starting at x. | |
| 17 | + | func shapeAt(engine *text.Engine, st *css.Style, s string, x, y, wrap, box float64) (text.Layout, []layout.Run, bool) { | |
| 18 | + | req := text.Request{Families: st.FontFamily, Weight: float32(st.FontWeight), Italic: st.FontStyle == css.KeywordItalic || st.FontStyle == css.KeywordOblique, Size: st.FontSize.Value} | |
| 19 | + | if st.LetterSpacing.Unit == "px" { // percentages resolve to 0, as in layout | |
| 20 | + | req.LetterSpacing = st.LetterSpacing.Value | |
| 21 | + | } | |
| 22 | + | if req.Size <= 0 { | |
| 23 | + | req.Size = 16 | |
| 24 | + | } | |
| 25 | + | measured, err := engine.Measure(s, req, wrap) | |
| 26 | + | if err != nil || len(measured.Lines) == 0 { | |
| 27 | + | return text.Layout{}, nil, false | |
| 28 | + | } | |
| 29 | + | var runs []layout.Run | |
| 30 | + | for _, line := range measured.Lines { | |
| 31 | + | lx := x | |
| 32 | + | if box > 0 { | |
| 33 | + | lx += layout.AlignOffset(st.TextAlign, line.Direction, box, line.Width) | |
| 34 | + | } | |
| 35 | + | for _, run := range line.Runs { | |
| 36 | + | rr := layout.Run{Start: run.Start, End: run.End, X: lx + run.X, Y: y + run.Y, Advance: run.Advance, Face: run.Face, FaceID: run.Face.ID, Size: req.Size} | |
| 37 | + | for _, g := range run.Glyphs { | |
| 38 | + | rr.Glyphs = append(rr.Glyphs, layout.Glyph{ID: uint32(g.ID), X: lx + g.X, Y: y + g.Y, Advance: g.Advance, Cluster: g.Cluster}) | |
| 39 | + | } | |
| 40 | + | runs = append(runs, rr) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | return measured, runs, true | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // placeholderCommand paints the placeholder of an empty editor in its content | |
| 47 | + | // box, aligned like editor text and dimmed to half the text opacity. Textarea | |
| 48 | + | // placeholders wrap at the content width. It never enters the bound value. | |
| 49 | + | func placeholderCommand(e *element, n *layout.Result, engine *text.Engine) []layout.Command { | |
| 50 | + | if e.computed == nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | st := e.computed.Style | |
| 54 | + | x, y := n.Content.X-n.ScrollX, n.Content.Y-n.ScrollY | |
| 55 | + | wrap := 0.0 | |
| 56 | + | if e.typ == "textarea" { | |
| 57 | + | wrap = n.Content.W | |
| 58 | + | } | |
| 59 | + | measured, runs, ok := shapeAt(engine, st, e.placeholder, x, y, wrap, n.Content.W) | |
| 60 | + | if !ok { | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | opacity := math.Min(1, st.Opacity) * 0.5 | |
| 64 | + | return []layout.Command{{Op: "text", ID: n.ID + "#placeholder", Rect: layout.Rect{X: x, Y: y, W: n.Content.W, H: measured.Height}, Text: e.placeholder, Runs: runs, Color: st.Color, Opacity: opacity}} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // preeditCommands shapes the composition independently of the bound value. | |
| 68 | + | // Its text command begins at the model caret and carries its own rune clusters; | |
| 69 | + | // the underline and composition cursor refer to UTF-8 byte positions. | |
| 70 | + | func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.Engine, display []layout.Command) []layout.Command { | |
| 71 | + | if s.Preedit == "" || n == nil || e.computed == nil { | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | value := s.Value | |
| 75 | + | cursor := s.Cursor | |
| 76 | + | if e.password { | |
| 77 | + | value = s.Mask() | |
| 78 | + | i := 0 | |
| 79 | + | for k, b := range edit.Boundaries(s.Value) { | |
| 80 | + | if b == cursor { | |
| 81 | + | i = k | |
| 82 | + | break | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | cursor = edit.Boundaries(value)[i] | |
| 86 | + | } | |
| 87 | + | var origin *textBoundary | |
| 88 | + | lines := textBoundaries(value, n, display) | |
| 89 | + | for i := range lines { | |
| 90 | + | for j := range lines[i] { | |
| 91 | + | if lines[i][j].byteOffset == cursor { | |
| 92 | + | origin = &lines[i][j] | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | x, y := n.Content.X, n.Content.Y | |
| 97 | + | if origin != nil { | |
| 98 | + | x, y = origin.x, origin.y | |
| 99 | + | } | |
| 100 | + | st := e.computed.Style | |
| 101 | + | preedit := s.Preedit | |
| 102 | + | if e.password { | |
| 103 | + | preedit = (&edit.State{Value: s.Preedit}).Mask() | |
| 104 | + | } | |
| 105 | + | measured, runs, ok := shapeAt(engine, st, preedit, x, y, 0, 0) | |
| 106 | + | if !ok { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | h := measured.LineHeight | |
| 110 | + | if h <= 0 { | |
| 111 | + | h = n.Content.H | |
| 112 | + | } | |
| 113 | + | width := measured.Width | |
| 114 | + | color := st.Color | |
| 115 | + | cmds := []layout.Command{{Op: "preedit-text", ID: n.ID, Rect: layout.Rect{X: x, Y: y, W: width, H: h}, Text: preedit, Runs: runs, Color: color, Opacity: 1}, {Op: "preedit-underline", ID: n.ID, Rect: layout.Rect{X: x, Y: y + h - 1, W: width, H: 1}, Color: color, Opacity: 1}} | |
| 116 | + | pos := s.PreeditBegin | |
| 117 | + | if pos < 0 { | |
| 118 | + | pos = 0 | |
| 119 | + | } | |
| 120 | + | if pos > len(s.Preedit) { | |
| 121 | + | pos = len(s.Preedit) | |
| 122 | + | } | |
| 123 | + | clamped := 0 | |
| 124 | + | for _, b := range edit.Boundaries(s.Preedit) { | |
| 125 | + | if b > pos { | |
| 126 | + | break | |
| 127 | + | } | |
| 128 | + | clamped = b | |
| 129 | + | } | |
| 130 | + | pos = clamped | |
| 131 | + | if e.password { | |
| 132 | + | // Preedit offsets are bytes in the original text; the display uses | |
| 133 | + | // one bullet per original grapheme, including combined clusters. | |
| 134 | + | index := 0 | |
| 135 | + | for _, b := range edit.Boundaries(s.Preedit) { | |
| 136 | + | if b >= pos { | |
| 137 | + | break | |
| 138 | + | } | |
| 139 | + | index++ | |
| 140 | + | } | |
| 141 | + | pos = edit.Boundaries(preedit)[index] | |
| 142 | + | } | |
| 143 | + | // The preedit cursor uses shaped cluster advances, not raw rune count. | |
| 144 | + | px := x | |
| 145 | + | pr := utf8.RuneCountInString(preedit[:pos]) | |
| 146 | + | for _, run := range measured.Lines[0].Runs { | |
| 147 | + | for _, g := range run.Glyphs { | |
| 148 | + | if g.Cluster < pr { | |
| 149 | + | px = math.Max(px, x+g.X+g.Advance) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | cmds = append(cmds, layout.Command{Op: "preedit-caret", ID: n.ID, Rect: layout.Rect{X: px, Y: y, W: 1, H: h}, Color: color, Opacity: 1}) | |
| 154 | + | return cmds | |
| 155 | + | } | |
A edit_preedit_password_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefergui/internal/css" | |
| 9 | + | "github.com/bnema/nefergui/internal/edit" | |
| 10 | + | "github.com/bnema/nefergui/internal/text" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestPasswordPreeditNeverLeaksIntoDisplay(t *testing.T) { | |
| 14 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | r := newRuntime() | |
| 19 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 20 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {width:250px;height:28px;font-size:16px}`)) | |
| 21 | + | value := "a" | |
| 22 | + | view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) } | |
| 23 | + | r.Build(view) | |
| 24 | + | r.route(key("Tab")) | |
| 25 | + | r.Build(view) | |
| 26 | + | secret := "e\u0301猫" | |
| 27 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: secret, Begin: len("e\u0301")}}) | |
| 28 | + | r.Build(view) | |
| 29 | + | data, err := json.Marshal(r.output.Display) | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if bytes.Contains(data, []byte(secret)) || bytes.Contains(data, []byte("猫")) || bytes.Contains(data, []byte("e\u0301")) { | |
| 34 | + | t.Fatalf("plaintext leaked: %s", data) | |
| 35 | + | } | |
| 36 | + | if value != "a" { | |
| 37 | + | t.Fatalf("preedit modified model: %q", value) | |
| 38 | + | } | |
| 39 | + | found := false | |
| 40 | + | for _, c := range r.output.Display { | |
| 41 | + | if c.Op == "preedit-text" { | |
| 42 | + | found = true | |
| 43 | + | if c.Text != "••" || len(c.Runs) == 0 { | |
| 44 | + | t.Fatalf("wrong grapheme masking: %+v", c) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if !found { | |
| 49 | + | t.Fatal("missing masked preedit") | |
| 50 | + | } | |
| 51 | + | } | |
A edit_preedit_test.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/nefergui/internal/css" | |
| 5 | + | "github.com/bnema/nefergui/internal/edit" | |
| 6 | + | "github.com/bnema/nefergui/internal/text" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestPreeditInlineWithoutModelMutation(t *testing.T) { | |
| 11 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | r := newRuntime() | |
| 16 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 17 | + | r.styles = css.Compile(css.UA(), css.Parse(`input {width:250px;height:28px;font-size:16px}`)) | |
| 18 | + | value := "ab" | |
| 19 | + | view := func(f *Frame) { f.Root().Input("preedit", &value) } | |
| 20 | + | r.Build(view) | |
| 21 | + | r.route(key("Tab")) | |
| 22 | + | r.Build(view) | |
| 23 | + | id := idKey(r.Target(0)) | |
| 24 | + | r.edits[id].Move(1, false) | |
| 25 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "éx", Begin: len("é")}}) | |
| 26 | + | r.Build(view) | |
| 27 | + | if value != "ab" || r.committed.children[0].text != "ab" { | |
| 28 | + | t.Fatalf("composition changed model/display text: %q %q", value, r.committed.children[0].text) | |
| 29 | + | } | |
| 30 | + | seen := map[string]bool{} | |
| 31 | + | var start, width, caret float64 | |
| 32 | + | for _, cmd := range r.output.Display { | |
| 33 | + | if cmd.ID != id { | |
| 34 | + | continue | |
| 35 | + | } | |
| 36 | + | seen[cmd.Op] = true | |
| 37 | + | switch cmd.Op { | |
| 38 | + | case "preedit-text": | |
| 39 | + | if cmd.Text != "éx" || len(cmd.Runs) == 0 { | |
| 40 | + | t.Fatalf("unshaped preedit: %+v", cmd) | |
| 41 | + | } | |
| 42 | + | start = cmd.Rect.X | |
| 43 | + | width = cmd.Rect.W | |
| 44 | + | case "preedit-caret": | |
| 45 | + | caret = cmd.Rect.X | |
| 46 | + | case "preedit-underline": | |
| 47 | + | if cmd.Rect.W <= 0 || cmd.Rect.H != 1 { | |
| 48 | + | t.Fatalf("bad underline %+v", cmd) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if !seen["preedit-text"] || !seen["preedit-underline"] || !seen["preedit-caret"] || !(caret > start && caret < start+width) { | |
| 53 | + | t.Fatalf("preedit commands %v x=%g width=%g cursor=%g", seen, start, width, caret) | |
| 54 | + | } | |
| 55 | + | } | |
A edit_scroll.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (r *runtime) scrollCaret(root *element, out *layout.Output) bool { | |
| 10 | + | if root == nil || r.state.focus == nil { | |
| 11 | + | return false | |
| 12 | + | } | |
| 13 | + | id := idKey(r.state.focus) | |
| 14 | + | var find func(*element) *element | |
| 15 | + | find = func(e *element) *element { | |
| 16 | + | if e == nil { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | if idKey(e.identity) == id { | |
| 20 | + | return e | |
| 21 | + | } | |
| 22 | + | for _, child := range e.children { | |
| 23 | + | if v := find(child); v != nil { | |
| 24 | + | return v | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | e := find(root) | |
| 30 | + | if e == nil || e.disabled || (e.typ != "input" && e.typ != "textarea") { | |
| 31 | + | return false | |
| 32 | + | } | |
| 33 | + | s := r.edits[id] | |
| 34 | + | n := resultByID(out.Tree, id) | |
| 35 | + | if s == nil || n == nil { | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | rendered := e.text | |
| 39 | + | cursor := s.Cursor | |
| 40 | + | if e.password { | |
| 41 | + | cursor = len([]rune(s.Value[:s.Cursor])) * len("•") | |
| 42 | + | } | |
| 43 | + | lines := textBoundaries(rendered, n, out.Display) | |
| 44 | + | var caret *textBoundary | |
| 45 | + | for i := range lines { | |
| 46 | + | for j := range lines[i] { | |
| 47 | + | if lines[i][j].byteOffset == cursor { | |
| 48 | + | caret = &lines[i][j] | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if caret == nil { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | old := r.state.scroll[id] | |
| 56 | + | next := old | |
| 57 | + | if e.typ == "input" { | |
| 58 | + | if caret.x < n.Content.X { | |
| 59 | + | next.W += caret.x - n.Content.X | |
| 60 | + | } else if caret.x > n.Content.X+n.Content.W-1 { | |
| 61 | + | next.W += caret.x - (n.Content.X + n.Content.W - 1) | |
| 62 | + | } | |
| 63 | + | next.H = 0 | |
| 64 | + | } else { | |
| 65 | + | height := 0.0 | |
| 66 | + | if len(n.Lines) > 0 { | |
| 67 | + | height = n.Lines[0].Height | |
| 68 | + | } | |
| 69 | + | if caret.y < n.Content.Y { | |
| 70 | + | next.H += caret.y - n.Content.Y | |
| 71 | + | } else if caret.y+height > n.Content.Y+n.Content.H { | |
| 72 | + | next.H += caret.y + height - (n.Content.Y + n.Content.H) | |
| 73 | + | } | |
| 74 | + | next.W = 0 | |
| 75 | + | } | |
| 76 | + | next.W = math.Max(0, math.Min(next.W, math.Max(0, n.ContentSize.W-n.Content.W))) | |
| 77 | + | next.H = math.Max(0, math.Min(next.H, math.Max(0, n.ContentSize.H-n.Content.H))) | |
| 78 | + | if next == old { | |
| 79 | + | return false | |
| 80 | + | } | |
| 81 | + | if r.state.scroll == nil { | |
| 82 | + | r.state.scroll = make(map[string]layout.Size) | |
| 83 | + | } | |
| 84 | + | r.state.scroll[id] = next | |
| 85 | + | return true | |
| 86 | + | } | |
A example_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package nefergui_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // The product-design example. Run needs a Wayland session, so this example is | |
| 10 | + | // compiled but not executed by go test; examples/demo runs it. | |
| 11 | + | func Example_compileOnly() { | |
| 12 | + | type Model struct { | |
| 13 | + | Page, Name, Status string | |
| 14 | + | DarkMode bool | |
| 15 | + | } | |
| 16 | + | model := Model{Page: "home", Status: "Prêt"} | |
| 17 | + | navButton := func(parent nefergui.Node, m *Model, label, page string) { | |
| 18 | + | opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")} | |
| 19 | + | if m.Page == page { | |
| 20 | + | opts = append(opts, nefergui.Class("active")) | |
| 21 | + | } | |
| 22 | + | if parent.Button(label, opts...).Activated() { | |
| 23 | + | m.Page = page | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | home := func(parent nefergui.Node, m *Model) { | |
| 27 | + | section := parent.Section(nefergui.Class("welcome")) | |
| 28 | + | section.Heading("Bienvenue", nefergui.Level(2)) | |
| 29 | + | section.Text("Une interface native écrite entièrement en Go.") | |
| 30 | + | section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada")) | |
| 31 | + | actions := section.Row(nefergui.Class("actions")) | |
| 32 | + | if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() { | |
| 33 | + | m.Status = "Bonjour " + m.Name | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | app := func(f *nefergui.Frame, m *Model) { | |
| 37 | + | root := f.Root(nefergui.Class("app")) | |
| 38 | + | header := root.Header(nefergui.Class("header")) | |
| 39 | + | header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo")) | |
| 40 | + | menu := header.Nav(nefergui.Class("menu")) | |
| 41 | + | navButton(menu, m, "Accueil", "home") | |
| 42 | + | navButton(menu, m, "Paramètres", "settings") | |
| 43 | + | header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode")) | |
| 44 | + | content := root.Main(nefergui.Class("content")) | |
| 45 | + | switch m.Page { | |
| 46 | + | case "home": | |
| 47 | + | home(content, m) | |
| 48 | + | case "settings": | |
| 49 | + | home(content, m) | |
| 50 | + | } | |
| 51 | + | root.Footer(nefergui.Class("status-bar")).Text(m.Status) | |
| 52 | + | } | |
| 53 | + | _ = nefergui.Run(context.Background(), &model, app, nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles("app.css")) | |
| 54 | + | } | |
A examples/demo/app.css +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | app.app { background-color: #f5f7fa; color: #18253b; padding: 20px; } | |
| 2 | + | header.header { background-color: #dae5f5; padding: 14px; height: 130px; } | |
| 3 | + | heading.logo { color: #203d7a; font-size: 32px; } | |
| 4 | + | nav.menu { display: flex; flex-direction: row; column-gap: 8px; padding: 8px; } | |
| 5 | + | button.menu-item { background-color: #aabbd8; padding: 6px 12px; } | |
| 6 | + | button.menu-item:hover { background-color: #bccae2; } | |
| 7 | + | button.menu-item.active { background-color: #8fa6cf; } | |
| 8 | + | main.content { padding: 24px; } | |
| 9 | + | section.welcome { background-color: #ffffff; padding: 20px; } | |
| 10 | + | heading { color: #203d7a; font-size: 28px; } | |
| 11 | + | input { background-color: #f1f5fc; border: 2px solid #5675a4; padding: 8px; width: 300px; height: 42px; } | |
| 12 | + | row.actions { padding: 12px; } | |
| 13 | + | button.primary { background-color: #2357bb; color: #ffffff; padding: 13px 10px; width: 160px; } | |
| 14 | + | button.primary:hover { background-color: #1b4699; } | |
| 15 | + | button.primary:disabled { background-color: #7594c9; } | |
| 16 | + | input:hover { border-color: #203d7a; } | |
| 17 | + | radio { padding-top: 6px; padding-bottom: 6px; } | |
| 18 | + | slider { width: 300px; margin-top: 12px; } | |
| 19 | + | footer.status-bar { background-color: #dae5f5; padding: 12px; height: 42px; } | |
A examples/demo/main.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | _ "embed" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "flag" | |
| 11 | + | "fmt" | |
| 12 | + | "math" | |
| 13 | + | "os" | |
| 14 | + | "os/signal" | |
| 15 | + | "path/filepath" | |
| 16 | + | "strconv" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/nefergui" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | //go:embed app.css | |
| 23 | + | var stylesheet []byte | |
| 24 | + | ||
| 25 | + | type Model struct { | |
| 26 | + | Page, Name, Status string | |
| 27 | + | DarkMode bool | |
| 28 | + | Density string | |
| 29 | + | Volume float64 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func navButton(parent nefergui.Node, m *Model, label, page string) { | |
| 33 | + | opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")} | |
| 34 | + | if m.Page == page { | |
| 35 | + | opts = append(opts, nefergui.Class("active")) | |
| 36 | + | } | |
| 37 | + | if parent.Button(label, opts...).Activated() { | |
| 38 | + | m.Page = page | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | func home(parent nefergui.Node, m *Model) { | |
| 42 | + | section := parent.Section(nefergui.Class("welcome")) | |
| 43 | + | section.Heading("Bienvenue", nefergui.Level(2)) | |
| 44 | + | section.Text("Une interface native écrite entièrement en Go.") | |
| 45 | + | section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada")) | |
| 46 | + | actions := section.Row(nefergui.Class("actions")) | |
| 47 | + | if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() { | |
| 48 | + | m.Status = "Bonjour " + m.Name | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | func settings(parent nefergui.Node, m *Model) { | |
| 52 | + | section := parent.Section(nefergui.Class("welcome")) | |
| 53 | + | section.Heading("Paramètres", nefergui.Level(2)) | |
| 54 | + | for _, d := range []struct{ label, value string }{{"Confortable", "comfortable"}, {"Compacte", "compact"}} { | |
| 55 | + | section.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value)) | |
| 56 | + | } | |
| 57 | + | section.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume")) | |
| 58 | + | } | |
| 59 | + | func app(f *nefergui.Frame, m *Model) { | |
| 60 | + | root := f.Root(nefergui.Class("app")) | |
| 61 | + | header := root.Header(nefergui.Class("header")) | |
| 62 | + | header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo")) | |
| 63 | + | menu := header.Nav(nefergui.Class("menu")) | |
| 64 | + | navButton(menu, m, "Accueil", "home") | |
| 65 | + | navButton(menu, m, "Paramètres", "settings") | |
| 66 | + | header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode")) | |
| 67 | + | content := root.Main(nefergui.Class("content")) | |
| 68 | + | switch m.Page { | |
| 69 | + | case "home": | |
| 70 | + | home(content, m) | |
| 71 | + | case "settings": | |
| 72 | + | settings(content, m) | |
| 73 | + | } | |
| 74 | + | root.Footer(nefergui.Class("status-bar")).Text(m.Status) | |
| 75 | + | } | |
| 76 | + | func main() { | |
| 77 | + | static := flag.Bool("static", false, "render a fixed number of frames and exit") | |
| 78 | + | frames := flag.Int("frames", 120, "static frame count") | |
| 79 | + | minDuration := flag.Duration("min-duration", 0, "static: keep presenting until at least this long after start (for scripted input)") | |
| 80 | + | captureFrame := flag.Int("capture-frame", 0, "commit number for harness capture request") | |
| 81 | + | flag.Parse() | |
| 82 | + | if *static && (*frames < 2 || *captureFrame < 0 || *captureFrame >= *frames) { | |
| 83 | + | fmt.Fprintln(os.Stderr, "invalid frame count or capture frame") | |
| 84 | + | os.Exit(2) | |
| 85 | + | } | |
| 86 | + | if *captureFrame == 0 { | |
| 87 | + | *captureFrame = *frames - 8 | |
| 88 | + | if *captureFrame < 1 { | |
| 89 | + | *captureFrame = 1 | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) | |
| 93 | + | defer stop() | |
| 94 | + | if *static { | |
| 95 | + | // Scripted runs must end even if the frame budget is never reached; | |
| 96 | + | // the interactive demo runs until closed or interrupted. | |
| 97 | + | var cancel context.CancelFunc | |
| 98 | + | ctx, cancel = context.WithTimeout(ctx, 60*time.Second) | |
| 99 | + | defer cancel() | |
| 100 | + | } | |
| 101 | + | dir := os.Getenv("NEFERGUI_DEBUG_DIR") | |
| 102 | + | var path string | |
| 103 | + | if dir != "" { | |
| 104 | + | path = filepath.Join(dir, "app.css") | |
| 105 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |
| 106 | + | fail(err) | |
| 107 | + | } | |
| 108 | + | if err := os.WriteFile(path, stylesheet, 0600); err != nil { | |
| 109 | + | fail(err) | |
| 110 | + | } | |
| 111 | + | } else { | |
| 112 | + | file, err := os.CreateTemp("", "nefergui-demo-*.css") | |
| 113 | + | if err != nil { | |
| 114 | + | fail(err) | |
| 115 | + | } | |
| 116 | + | path = file.Name() | |
| 117 | + | defer os.Remove(path) | |
| 118 | + | if _, err = file.Write(stylesheet); err != nil { | |
| 119 | + | file.Close() | |
| 120 | + | fail(err) | |
| 121 | + | } | |
| 122 | + | if err = file.Close(); err != nil { | |
| 123 | + | fail(err) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | model := Model{Page: "home", Status: "Prêt", Density: "comfortable", Volume: 40} | |
| 127 | + | start := time.Now() | |
| 128 | + | var requested time.Time | |
| 129 | + | commit := func(frame uint64) error { | |
| 130 | + | if dir != "" { | |
| 131 | + | data, err := json.Marshal(struct { | |
| 132 | + | Page, Name, Status, Density string | |
| 133 | + | DarkMode bool | |
| 134 | + | Volume float64 | |
| 135 | + | }{model.Page, model.Name, model.Status, model.Density, model.DarkMode, model.Volume}) | |
| 136 | + | if err != nil { | |
| 137 | + | return err | |
| 138 | + | } | |
| 139 | + | if err = os.WriteFile(filepath.Join(dir, "demo-state.json"), data, 0600); err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | if *static && int(frame) == *captureFrame && dir != "" { | |
| 144 | + | p := filepath.Join(dir, "capture-request") | |
| 145 | + | tmp := p + ".tmp" | |
| 146 | + | if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | if err := os.Rename(tmp, p); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | requested = time.Now() | |
| 153 | + | } | |
| 154 | + | if *static && int(frame) >= *frames && !requested.IsZero() { | |
| 155 | + | time.Sleep(time.Until(requested.Add(250 * time.Millisecond))) | |
| 156 | + | } | |
| 157 | + | // Frame count alone depends on render speed: keep presenting (and | |
| 158 | + | // handling input) until the minimum duration has also elapsed. | |
| 159 | + | if *static && int(frame) >= *frames && time.Since(start) >= *minDuration { | |
| 160 | + | return errDone | |
| 161 | + | } | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | opts := []nefergui.WindowOption{nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles(path)} | |
| 165 | + | var err error | |
| 166 | + | if *static { | |
| 167 | + | // The commit callback ends the run with errDone; the frame limit is a backstop. | |
| 168 | + | err = nefergui.RunFrames(ctx, math.MaxInt32, &model, app, commit, opts...) | |
| 169 | + | } else { | |
| 170 | + | err = nefergui.Run(ctx, &model, app, opts...) | |
| 171 | + | } | |
| 172 | + | if err = unexpected(err); err != nil { | |
| 173 | + | fail(err) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | var errDone = errors.New("demo: static run complete") | |
| 178 | + | ||
| 179 | + | // unexpected drops the normal stop reasons (errDone, cancellation) from a | |
| 180 | + | // possibly joined error while keeping everything else, such as close errors. | |
| 181 | + | func unexpected(err error) error { | |
| 182 | + | if joined, ok := err.(interface{ Unwrap() []error }); ok { | |
| 183 | + | var rest []error | |
| 184 | + | for _, e := range joined.Unwrap() { | |
| 185 | + | if e = unexpected(e); e != nil { | |
| 186 | + | rest = append(rest, e) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | return errors.Join(rest...) | |
| 190 | + | } | |
| 191 | + | if errors.Is(err, errDone) || errors.Is(err, context.Canceled) { | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func fail(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) } | |
A examples/demo/main_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestUnexpectedKeepsCloseErrors(t *testing.T) { | |
| 11 | + | closeErr := errors.New("close failed") | |
| 12 | + | cases := []struct { | |
| 13 | + | name string | |
| 14 | + | in error | |
| 15 | + | want error | |
| 16 | + | }{ | |
| 17 | + | {"nil", nil, nil}, | |
| 18 | + | {"done", errDone, nil}, | |
| 19 | + | {"canceled", fmt.Errorf("run: %w", context.Canceled), nil}, | |
| 20 | + | {"done and close", errors.Join(errDone, closeErr), closeErr}, | |
| 21 | + | {"close only", errors.Join(nil, closeErr), closeErr}, | |
| 22 | + | } | |
| 23 | + | for _, c := range cases { | |
| 24 | + | got := unexpected(c.in) | |
| 25 | + | if c.want == nil && got != nil || c.want != nil && !errors.Is(got, c.want) { | |
| 26 | + | t.Errorf("%s: unexpected(%v) = %v, want %v", c.name, c.in, got, c.want) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | if got := unexpected(errors.Join(errDone, closeErr)); errors.Is(got, errDone) { | |
| 30 | + | t.Errorf("errDone kept in %v", got) | |
| 31 | + | } | |
| 32 | + | } | |
A examples/rect/main.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | // Rect exercises presentation directly, without the immediate API. | |
| 4 | + | package main | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "flag" | |
| 10 | + | "fmt" | |
| 11 | + | "image" | |
| 12 | + | "image/color" | |
| 13 | + | "math" | |
| 14 | + | "os" | |
| 15 | + | "os/signal" | |
| 16 | + | "path/filepath" | |
| 17 | + | "runtime" | |
| 18 | + | "strconv" | |
| 19 | + | "strings" | |
| 20 | + | "time" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/nefergui/internal/css" | |
| 23 | + | "github.com/bnema/nefergui/internal/layout" | |
| 24 | + | "github.com/bnema/nefergui/internal/presentation/session" | |
| 25 | + | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |
| 26 | + | "github.com/bnema/nefergui/internal/render" | |
| 27 | + | "github.com/bnema/nefergui/internal/text" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | func main() { | |
| 31 | + | var frames, captureFrame int | |
| 32 | + | var size, textString string | |
| 33 | + | var textScale float64 | |
| 34 | + | var transparent, static, list, styles, imageMode, changingImage bool | |
| 35 | + | flag.BoolVar(&static, "static", false, "fixed rectangle and color for visual acceptance tests") | |
| 36 | + | flag.BoolVar(&list, "list", false, "draw three overlapping instanced quads") | |
| 37 | + | flag.BoolVar(&styles, "styles", false, "draw CSS-style rounded border, outline and shadow") | |
| 38 | + | flag.BoolVar(&imageMode, "image", false, "draw two scaled checkerboard images") | |
| 39 | + | flag.BoolVar(&changingImage, "changing-image", false, "image mode: pass a new image value each frame") | |
| 40 | + | flag.StringVar(&textString, "text", "", "draw glyphs using the GPU atlas") | |
| 41 | + | flag.Float64Var(&textScale, "scale", 1, "text mode: physical pixels per logical pixel") | |
| 42 | + | flag.IntVar(&frames, "frames", 0, "frames before exit (0 = until close)") | |
| 43 | + | flag.IntVar(&captureFrame, "capture-frame", 0, "static: committed frame count for capture request (0 = frames-8)") | |
| 44 | + | flag.StringVar(&size, "size", "400x300", "initial logical window width x height") | |
| 45 | + | flag.BoolVar(&transparent, "transparent", false, "use ARGB and premultiplied transparency") | |
| 46 | + | flag.Parse() | |
| 47 | + | width, height, err := parseSize(size) | |
| 48 | + | if err == nil && static && frames > 0 { | |
| 49 | + | if captureFrame == 0 { | |
| 50 | + | captureFrame = frames - 8 | |
| 51 | + | } | |
| 52 | + | if captureFrame < 1 || captureFrame >= frames { | |
| 53 | + | err = fmt.Errorf("--capture-frame must be between 1 and --frames-1") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if err == nil && (textString != "" || imageMode || styles) && (textScale <= 0 || math.IsNaN(textScale) || math.IsInf(textScale, 0)) { | |
| 57 | + | err = fmt.Errorf("invalid text scale") | |
| 58 | + | } | |
| 59 | + | if err != nil { | |
| 60 | + | fmt.Fprintln(os.Stderr, err) | |
| 61 | + | os.Exit(2) | |
| 62 | + | } | |
| 63 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) | |
| 64 | + | defer stop() | |
| 65 | + | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) | |
| 66 | + | defer cancel() | |
| 67 | + | s, err := session.Open("", width, height, transparent) | |
| 68 | + | if err != nil { | |
| 69 | + | fmt.Fprintln(os.Stderr, "presentation:", err) | |
| 70 | + | os.Exit(1) | |
| 71 | + | } | |
| 72 | + | closed := false | |
| 73 | + | defer func() { | |
| 74 | + | if !closed { | |
| 75 | + | _ = s.Close() | |
| 76 | + | } | |
| 77 | + | }() | |
| 78 | + | fmt.Printf("presentation: ready main_device=%d render=%d:%d logical=%dx%d physical=%dx%d transparent=%v\n", s.Window.MainDevice, s.Device.Render.Major, s.Device.Render.Minor, s.Window.Width, s.Window.Height, s.Width, s.Height, transparent) | |
| 79 | + | var requested time.Time | |
| 80 | + | draw := func(i int) vkdevice.Rect { | |
| 81 | + | if static { | |
| 82 | + | // The render pass clears to transparent black (#00000000). XRGB | |
| 83 | + | // displays that as opaque black. Straight #ff8000 at alpha 1 | |
| 84 | + | // reads back as #ff8000ff; at alpha 0.5 the shader writes | |
| 85 | + | // premultiplied RGB (~128,64,0) and alpha ~128. | |
| 86 | + | alpha := float32(1) | |
| 87 | + | if transparent { | |
| 88 | + | alpha = 0.5 | |
| 89 | + | } | |
| 90 | + | return vkdevice.Rect{Bounds: [4]float32{0.25, 0.25, 0.5, 0.5}, Color: [4]float32{1, 128.0 / 255, 0, alpha}} | |
| 91 | + | } | |
| 92 | + | phase := float64(i) * 0.08 | |
| 93 | + | alpha := float32(1) | |
| 94 | + | if transparent { | |
| 95 | + | alpha = 0.5 | |
| 96 | + | } | |
| 97 | + | return vkdevice.Rect{Bounds: [4]float32{float32(0.25 + 0.2*math.Sin(phase)), 0.25, 0.5, 0.5}, Color: [4]float32{float32(0.5 + 0.5*math.Sin(phase)), 0.25, 0.8, alpha}} | |
| 98 | + | } | |
| 99 | + | committed := func(committed int) error { | |
| 100 | + | if static && committed == captureFrame && os.Getenv("NEFERGUI_DEBUG_DIR") != "" { | |
| 101 | + | // Publish only a complete timestamp: the harness may observe this | |
| 102 | + | // file while the client is still presenting subsequent frames. | |
| 103 | + | dir := os.Getenv("NEFERGUI_DEBUG_DIR") | |
| 104 | + | path := filepath.Join(dir, "capture-request") | |
| 105 | + | tmp := path + ".tmp" | |
| 106 | + | if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | if err := os.Rename(tmp, path); err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | requested = time.Now() | |
| 113 | + | } | |
| 114 | + | // Stay mapped long enough for the harness to see two later screenshots. | |
| 115 | + | if static && committed == frames && !requested.IsZero() { | |
| 116 | + | time.Sleep(time.Until(requested.Add(250 * time.Millisecond))) | |
| 117 | + | } | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | var count int | |
| 121 | + | if imageMode { | |
| 122 | + | count, err = s.RunPreparedListWithCommit(ctx, frames, func(i int) (render.Frame, error) { | |
| 123 | + | return s.Preparer.Prepare(imageCommands(i, changingImage, float64(s.Width)/textScale, float64(s.Height)/textScale), textScale, int(s.Width), int(s.Height)) | |
| 124 | + | }, committed) | |
| 125 | + | } else if textString != "" { | |
| 126 | + | _, source, _, _ := runtime.Caller(0) | |
| 127 | + | catalog, e := text.Load(text.DirectorySource(filepath.Join(filepath.Dir(source), "..", "..", "testdata", "fonts"))) | |
| 128 | + | if e != nil { | |
| 129 | + | fmt.Fprintln(os.Stderr, "text fonts:", e) | |
| 130 | + | os.Exit(1) | |
| 131 | + | } | |
| 132 | + | measured, e := text.NewEngine(catalog).Measure(textString, text.Request{Families: []string{"Noto Sans"}, Size: 32}, 0) | |
| 133 | + | if e != nil { | |
| 134 | + | fmt.Fprintln(os.Stderr, "text measure:", e) | |
| 135 | + | os.Exit(1) | |
| 136 | + | } | |
| 137 | + | cmd := layout.Command{Op: "text", Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1} | |
| 138 | + | for _, line := range measured.Lines { | |
| 139 | + | for _, shaped := range line.Runs { | |
| 140 | + | r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 32} | |
| 141 | + | for _, g := range shaped.Glyphs { | |
| 142 | + | r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 20 + g.X, Y: 40 + g.Y}) | |
| 143 | + | } | |
| 144 | + | cmd.Runs = append(cmd.Runs, r) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) { | |
| 148 | + | return s.Preparer.Prepare([]layout.Command{{Op: "rect", Rect: layout.Rect{W: float64(s.Width) / textScale, H: float64(s.Height) / textScale}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1}, cmd}, textScale, int(s.Width), int(s.Height)) | |
| 149 | + | }, committed) | |
| 150 | + | } else if styles { | |
| 151 | + | count, err = s.RunPreparedListWithCommit(ctx, frames, func(int) (render.Frame, error) { | |
| 152 | + | return s.Preparer.Prepare(styleCommands(), textScale, int(s.Width), int(s.Height)) | |
| 153 | + | }, committed) | |
| 154 | + | } else if list { | |
| 155 | + | count, err = s.RunListWithCommit(ctx, frames, func(int) []vkdevice.Instance { return listQuads(s.Width, s.Height) }, committed) | |
| 156 | + | } else { | |
| 157 | + | count, err = s.RunWithCommit(ctx, frames, draw, committed) | |
| 158 | + | } | |
| 159 | + | windowClosed := s.Window.Closed | |
| 160 | + | closed = true | |
| 161 | + | if cerr := s.Close(); cerr != nil && (err == nil || errors.Is(err, context.Canceled)) { | |
| 162 | + | err = cerr | |
| 163 | + | } | |
| 164 | + | fmt.Printf("presentation: frames=%d closed=%v err=%v\n", count, windowClosed, err) | |
| 165 | + | if err != nil && !errors.Is(err, context.Canceled) { | |
| 166 | + | os.Exit(1) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // imageCommands mixes ordinary and image quads to exercise painter's order. | |
| 171 | + | func imageCommands(frame int, changing bool, width, height float64) []layout.Command { | |
| 172 | + | checker := image.NewNRGBA(image.Rect(0, 0, 2, 2)) | |
| 173 | + | checker.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255}) | |
| 174 | + | checker.SetNRGBA(1, 0, color.NRGBA{G: 255, A: 255}) | |
| 175 | + | checker.SetNRGBA(0, 1, color.NRGBA{B: 255, A: 255}) | |
| 176 | + | checker.SetNRGBA(1, 1, color.NRGBA{R: 255, G: 255, A: 255}) | |
| 177 | + | if changing { | |
| 178 | + | checker.Pix[0] = uint8(frame) | |
| 179 | + | } | |
| 180 | + | second := image.NewNRGBA(image.Rect(0, 0, 2, 2)) | |
| 181 | + | for y := 0; y < 2; y++ { | |
| 182 | + | for x := 0; x < 2; x++ { | |
| 183 | + | second.SetNRGBA(x, y, color.NRGBA{R: 255, G: 128, A: 255}) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | return []layout.Command{ | |
| 187 | + | {Op: "rect", Rect: layout.Rect{W: width, H: height}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1}, | |
| 188 | + | {Op: "image", Rect: layout.Rect{X: 100, Y: 100, W: 64, H: 64}, Radii: [4]float64{8, 8, 8, 8}, Image: checker, Opacity: .5}, | |
| 189 | + | {Op: "rect", Rect: layout.Rect{X: 180, Y: 100, W: 20, H: 20}, Color: css.Color{R: 1, A: 1}, Opacity: 1}, | |
| 190 | + | {Op: "image", Rect: layout.Rect{X: 210, Y: 100, W: 64, H: 64}, Image: second, Opacity: 1}, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func parseSize(size string) (int32, int32, error) { | |
| 195 | + | w, h, ok := strings.Cut(size, "x") | |
| 196 | + | if !ok { | |
| 197 | + | return 0, 0, fmt.Errorf("invalid --size %q", size) | |
| 198 | + | } | |
| 199 | + | x, err := strconv.ParseInt(w, 10, 32) | |
| 200 | + | if err != nil || x <= 0 { | |
| 201 | + | return 0, 0, fmt.Errorf("invalid --size %q", size) | |
| 202 | + | } | |
| 203 | + | y, err := strconv.ParseInt(h, 10, 32) | |
| 204 | + | if err != nil || y <= 0 { | |
| 205 | + | return 0, 0, fmt.Errorf("invalid --size %q", size) | |
| 206 | + | } | |
| 207 | + | return int32(x), int32(y), nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // listQuads uses physical pixels and keeps each probe away from antialiased edges. | |
| 211 | + | func listQuads(w, h int32) []vkdevice.Instance { | |
| 212 | + | clip := [4]float32{0, 0, float32(w), float32(h)} | |
| 213 | + | return []vkdevice.Instance{ | |
| 214 | + | {Bounds: [4]float32{100, 100, 200, 200}, Clip: clip, Radii: [4]float32{20, 20, 20, 20}, Color: [4]float32{1, 0, 0, 1}}, | |
| 215 | + | {Bounds: [4]float32{220, 120, 200, 200}, Clip: [4]float32{220, 120, 100, 200}, Color: [4]float32{0, 1, 0, 1}}, | |
| 216 | + | {Bounds: [4]float32{140, 180, 100, 100}, Clip: clip, Color: [4]float32{0, 0, 1, .5}}, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // styleCommands uses logical coordinates; --scale converts them to physical. | |
| 221 | + | // Outset shadows precede the fill, while outlines follow the border. | |
| 222 | + | func styleCommands() []layout.Command { | |
| 223 | + | box := layout.Rect{X: 120, Y: 120, W: 160, H: 120} | |
| 224 | + | radius := [4]float64{20, 20, 20, 20} | |
| 225 | + | sh := css.Shadow{X: css.Length{Value: 0, Unit: "px"}, Y: css.Length{Value: 8, Unit: "px"}, Blur: css.Length{Value: 12, Unit: "px"}, Color: css.Color{A: .9}} | |
| 226 | + | return []layout.Command{ | |
| 227 | + | {Op: "rect", Rect: layout.Rect{W: 800, H: 600}, Color: css.Color{R: 17.0 / 255, G: 17.0 / 255, B: 17.0 / 255, A: 1}, Opacity: 1}, | |
| 228 | + | {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1}, | |
| 229 | + | {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}, | |
| 230 | + | {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |
| 231 | + | // Layout has already expanded this rect by the 4px outline offset. | |
| 232 | + | {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1}, | |
| 233 | + | } | |
| 234 | + | } | |
A go.mod +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | module github.com/bnema/nefergui | |
| 2 | + | ||
| 3 | + | go 1.27.0 | |
| 4 | + | ||
| 5 | + | require ( | |
| 6 | + | github.com/bnema/purego v0.13.0-bnema.1 | |
| 7 | + | github.com/bnema/purego-vulkan v0.5.1-0.20260928084713-7e6b00ce8c4c | |
| 8 | + | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 | |
| 9 | + | github.com/bnema/wlturbo v0.2.1-0.20260929151656-a2cb70a5513a | |
| 10 | + | github.com/go-text/typesetting v0.3.4 | |
| 11 | + | golang.org/x/image v0.46.0 | |
| 12 | + | golang.org/x/sys v0.48.0 | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | require golang.org/x/text v0.42.0 // indirect | |
A go.sum +18 -0 generated file: collapsed
A ime_focus_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type orderedIME struct { | |
| 9 | + | calls []string | |
| 10 | + | active bool | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func (i *orderedIME) Enable() { | |
| 14 | + | if i.active { | |
| 15 | + | panic("IME enable before disable") | |
| 16 | + | } | |
| 17 | + | i.active = true | |
| 18 | + | i.calls = append(i.calls, "enable") | |
| 19 | + | } | |
| 20 | + | func (i *orderedIME) Disable() { | |
| 21 | + | if !i.active { | |
| 22 | + | panic("IME double disable") | |
| 23 | + | } | |
| 24 | + | i.active = false | |
| 25 | + | i.calls = append(i.calls, "disable") | |
| 26 | + | } | |
| 27 | + | func (i *orderedIME) Surrounding(string, int, int) {} | |
| 28 | + | func (i *orderedIME) CursorRect(float64, float64, float64, float64) {} | |
| 29 | + | func (i *orderedIME) ContentType(bool, bool) {} | |
| 30 | + | ||
| 31 | + | type recordingIME struct { | |
| 32 | + | surrounding []string | |
| 33 | + | password bool | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (i *recordingIME) Enable() {} | |
| 37 | + | func (i *recordingIME) Disable() {} | |
| 38 | + | func (i *recordingIME) Surrounding(text string, _, _ int) { | |
| 39 | + | i.surrounding = append(i.surrounding, text) | |
| 40 | + | } | |
| 41 | + | func (i *recordingIME) CursorRect(float64, float64, float64, float64) {} | |
| 42 | + | func (i *recordingIME) ContentType(password, _ bool) { i.password = password } | |
| 43 | + | ||
| 44 | + | func TestPasswordEditorNeverSendsSecretToIME(t *testing.T) { | |
| 45 | + | r := newRuntime() | |
| 46 | + | ime := &recordingIME{} | |
| 47 | + | r.ime = ime | |
| 48 | + | value := "hunter2" | |
| 49 | + | view := func(f *Frame) { f.Root().Input("secret", &value, Key("secret"), Password(true)) } | |
| 50 | + | r.Build(view) | |
| 51 | + | r.route(key("Tab")) | |
| 52 | + | r.Build(view) | |
| 53 | + | if !ime.password { | |
| 54 | + | t.Fatal("password content type not sent") | |
| 55 | + | } | |
| 56 | + | if len(ime.surrounding) == 0 { | |
| 57 | + | t.Fatal("no surrounding text update") | |
| 58 | + | } | |
| 59 | + | for _, s := range ime.surrounding { | |
| 60 | + | if s != "" { | |
| 61 | + | t.Fatalf("surrounding text leaked %q", s) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestIMEFocusTransferDisablesBeforeEnable(t *testing.T) { | |
| 67 | + | r := newRuntime() | |
| 68 | + | ime := &orderedIME{} | |
| 69 | + | r.ime = ime | |
| 70 | + | a, b := "one", "two" | |
| 71 | + | view := func(f *Frame) { root := f.Root(); root.Input("a", &a, Key("a")); root.Textarea("b", &b, Key("b")) } | |
| 72 | + | r.Build(view) | |
| 73 | + | r.route(key("Tab")) | |
| 74 | + | r.Build(view) | |
| 75 | + | r.route(key("Tab")) | |
| 76 | + | r.Build(view) | |
| 77 | + | r.route(key("Tab")) | |
| 78 | + | r.Build(view) | |
| 79 | + | if want := []string{"enable", "disable", "enable", "disable", "enable"}; !reflect.DeepEqual(ime.calls, want) { | |
| 80 | + | t.Fatalf("focus transition calls %v want %v", ime.calls, want) | |
| 81 | + | } | |
| 82 | + | if !r.edits[idKey(r.Target(0))].IMEActive() || r.edits[idKey(r.Target(1))].IMEActive() { | |
| 83 | + | t.Fatal("wrong active editor") | |
| 84 | + | } | |
| 85 | + | } | |
A ime_return_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/edit" | |
| 7 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func typed(name, text string) platformInput { | |
| 11 | + | return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // With text-input v3 the compositor routes keys to a grabbing input method; | |
| 15 | + | // keys that still arrive on wl_keyboard were not consumed and must be typed. | |
| 16 | + | func TestKeyboardTextInsertedWhileIMEEnabled(t *testing.T) { | |
| 17 | + | r := newRuntime() | |
| 18 | + | ime := &orderedIME{} | |
| 19 | + | r.ime = ime | |
| 20 | + | value := "ab" | |
| 21 | + | view := func(f *Frame) { f.Root().Textarea("message", &value) } | |
| 22 | + | r.Build(view) | |
| 23 | + | r.route(key("Tab")) | |
| 24 | + | r.Build(view) | |
| 25 | + | if !r.edits[idKey(r.Target(0))].IMEActive() { | |
| 26 | + | t.Fatal("IME not enabled on focus") | |
| 27 | + | } | |
| 28 | + | for _, k := range []platformInput{typed("c", "c"), typed("a", "a"), key("Return"), key("KP_Enter")} { | |
| 29 | + | r.route(k) | |
| 30 | + | r.Build(view) | |
| 31 | + | } | |
| 32 | + | if value != "abca\n\n" || len(ime.calls) == 0 { | |
| 33 | + | t.Fatalf("keyboard text lost while IME enabled: %q", value) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // A shown preedit belongs to the input method until it commits: plain | |
| 37 | + | // keys and Return must not interleave with it. | |
| 38 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "ni", Begin: 2, End: 2}}) | |
| 39 | + | r.Build(view) | |
| 40 | + | for _, k := range []platformInput{typed("x", "x"), typed("a", "a"), key("Return")} { | |
| 41 | + | r.route(k) | |
| 42 | + | r.Build(view) | |
| 43 | + | } | |
| 44 | + | if value != "abca\n\n" { | |
| 45 | + | t.Fatalf("plain keys inserted during preedit: %q", value) | |
| 46 | + | } | |
| 47 | + | r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Commit: "你"}}) | |
| 48 | + | r.Build(view) | |
| 49 | + | r.route(typed("z", "z")) | |
| 50 | + | r.Build(view) | |
| 51 | + | if value != "abca\n\n你z" { | |
| 52 | + | t.Fatalf("typing after commit: %q", value) | |
| 53 | + | } | |
| 54 | + | } | |
A indicator_paint.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // paintIndicators adds checkbox and radio indicators and slider tracks after | |
| 11 | + | // layout. They sit in the room the UA sheet reserves (checkbox and radio | |
| 12 | + | // padding-left, slider padding-bottom), are sized from font-size and colored | |
| 13 | + | // from accent-color and color, so author CSS drives them like any other paint. | |
| 14 | + | func (r *runtime) paintIndicators(root *element, out *layout.Output) { | |
| 15 | + | var visit func(*element) | |
| 16 | + | visit = func(e *element) { | |
| 17 | + | if e == nil { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | if (e.typ == "checkbox" || e.typ == "radio" || e.typ == "slider") && e.computed != nil { | |
| 21 | + | id := idKey(e.identity) | |
| 22 | + | if n := resultByID(out.Tree, id); n != nil { | |
| 23 | + | insertOwnCommands(out, id, indicatorCommands(e, n, e.computed.Style)) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | for _, child := range e.children { | |
| 27 | + | visit(child) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | visit(root) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // indicatorCommands returns the paint for one control. The indicator is one | |
| 34 | + | // em square (16px when font-size is unset). | |
| 35 | + | func indicatorCommands(e *element, n *layout.Result, s *css.Style) []layout.Command { | |
| 36 | + | size := s.FontSize.Value | |
| 37 | + | if size <= 0 { | |
| 38 | + | size = 16 | |
| 39 | + | } | |
| 40 | + | base := layout.Command{ID: n.ID, Opacity: math.Min(1, math.Max(0, s.Opacity))} | |
| 41 | + | muted := s.Color | |
| 42 | + | muted.A *= 0.45 | |
| 43 | + | if e.typ == "slider" { | |
| 44 | + | return sliderCommands(e, n, base, size, s.AccentColor, muted) | |
| 45 | + | } | |
| 46 | + | // Center the box in the reserved left padding, and vertically on the | |
| 47 | + | // content box so it lines up with the label. | |
| 48 | + | left := n.Border.X + n.BorderWidths.Left | |
| 49 | + | x := left + math.Max(0, (n.Padding.Left-size)/2) | |
| 50 | + | y := n.Content.Y + (n.Content.H-size)/2 | |
| 51 | + | box := layout.Rect{X: x, Y: y, W: size, H: size} | |
| 52 | + | radius := size * 0.2 | |
| 53 | + | if e.typ == "radio" { | |
| 54 | + | radius = size / 2 | |
| 55 | + | } | |
| 56 | + | radii := [4]float64{radius, radius, radius, radius} | |
| 57 | + | if !e.checked { | |
| 58 | + | w := math.Max(1, math.Round(size/10)) | |
| 59 | + | cmd := base | |
| 60 | + | // The renderer multiplies side alpha by Color alpha: keep Color opaque | |
| 61 | + | // so the sides carry the muted alpha once. | |
| 62 | + | opaque := muted | |
| 63 | + | opaque.A = 1 | |
| 64 | + | cmd.Op, cmd.Rect, cmd.Radii, cmd.Color = "border", box, radii, opaque | |
| 65 | + | cmd.Widths = layout.Edges{Top: w, Right: w, Bottom: w, Left: w} | |
| 66 | + | cmd.Colors = css.ColorSides{Top: muted, Right: muted, Bottom: muted, Left: muted} | |
| 67 | + | return []layout.Command{cmd} | |
| 68 | + | } | |
| 69 | + | fill := base | |
| 70 | + | fill.Op, fill.Rect, fill.Radii, fill.Color = "rect", box, radii, s.AccentColor | |
| 71 | + | // A contrasting inner mark: a dot for radios, a smaller square for | |
| 72 | + | // checkboxes (the renderer paints rounded boxes, not glyph paths). | |
| 73 | + | inner := size * 0.4 | |
| 74 | + | if e.typ == "checkbox" { | |
| 75 | + | inner = size * 0.5 | |
| 76 | + | } | |
| 77 | + | mark := base | |
| 78 | + | markRadius := inner / 2 | |
| 79 | + | if e.typ == "checkbox" { | |
| 80 | + | markRadius = inner * 0.2 | |
| 81 | + | } | |
| 82 | + | mark.Op, mark.Color = "rect", css.Color{R: 1, G: 1, B: 1, A: 1} | |
| 83 | + | mark.Rect = layout.Rect{X: x + (size-inner)/2, Y: y + (size-inner)/2, W: inner, H: inner} | |
| 84 | + | mark.Radii = [4]float64{markRadius, markRadius, markRadius, markRadius} | |
| 85 | + | return []layout.Command{fill, mark} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // sliderCommands paints the track under the label, in the reserved bottom | |
| 89 | + | // padding, across the content width the pointer maps to. The filled part and | |
| 90 | + | // the thumb follow the value fraction. | |
| 91 | + | func sliderCommands(e *element, n *layout.Result, base layout.Command, size float64, accent, muted css.Color) []layout.Command { | |
| 92 | + | // Without reserved bottom padding there is no room: paint nothing rather | |
| 93 | + | // than outside the control's box. | |
| 94 | + | band := n.Padding.Bottom | |
| 95 | + | if band <= 0 { | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | centerY := n.Content.Y + n.Content.H + band/2 | |
| 99 | + | trackH := math.Max(2, math.Round(size/4)) | |
| 100 | + | track := layout.Rect{X: n.Content.X, Y: centerY - trackH/2, W: n.Content.W, H: trackH} | |
| 101 | + | radii := [4]float64{trackH / 2, trackH / 2, trackH / 2, trackH / 2} | |
| 102 | + | rail := base | |
| 103 | + | rail.Op, rail.Rect, rail.Radii, rail.Color = "rect", track, radii, muted | |
| 104 | + | commands := []layout.Command{rail} | |
| 105 | + | if e.fraction < 0 { | |
| 106 | + | return commands | |
| 107 | + | } | |
| 108 | + | x := track.X + e.fraction*track.W | |
| 109 | + | if filled := x - track.X; filled > 0 { | |
| 110 | + | fill := base | |
| 111 | + | fill.Op, fill.Radii, fill.Color = "rect", radii, accent | |
| 112 | + | fill.Rect = layout.Rect{X: track.X, Y: track.Y, W: filled, H: track.H} | |
| 113 | + | commands = append(commands, fill) | |
| 114 | + | } | |
| 115 | + | d := math.Min(size, band) | |
| 116 | + | thumb := base | |
| 117 | + | thumb.Op, thumb.Color = "rect", accent | |
| 118 | + | thumb.Rect = layout.Rect{X: x - d/2, Y: centerY - d/2, W: d, H: d} | |
| 119 | + | thumb.Radii = [4]float64{d / 2, d / 2, d / 2, d / 2} | |
| 120 | + | return append(commands, thumb) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // insertOwnCommands places padding paint after the element's box paint and | |
| 124 | + | // before its content: at its clip-push when it clips (the clip is the content | |
| 125 | + | // box, which would hide the padding), otherwise after its last command. | |
| 126 | + | func insertOwnCommands(out *layout.Output, id string, commands []layout.Command) { | |
| 127 | + | at := -1 | |
| 128 | + | for i := range out.Display { | |
| 129 | + | if out.Display[i].ID != id { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | if out.Display[i].Op == "clip-push" { | |
| 133 | + | at = i | |
| 134 | + | break | |
| 135 | + | } | |
| 136 | + | at = i + 1 | |
| 137 | + | } | |
| 138 | + | insertAt(out, at, commands) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // insertClippedCommands places content decorations inside the element's clip, | |
| 142 | + | // after its text and before its clip-pop. Editors always clip. | |
| 143 | + | func insertClippedCommands(out *layout.Output, id string, commands []layout.Command) { | |
| 144 | + | for i := range out.Display { | |
| 145 | + | if out.Display[i].ID == id && out.Display[i].Op == "clip-pop" { | |
| 146 | + | insertAt(out, i, commands) | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | func insertAt(out *layout.Output, at int, commands []layout.Command) { | |
| 152 | + | if at < 0 || len(commands) == 0 { | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | out.Display = append(out.Display[:at], append(commands, out.Display[at:]...)...) | |
| 156 | + | } | |
A indicator_paint_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func ownCommands(r *runtime, id string) []layout.Command { | |
| 12 | + | var out []layout.Command | |
| 13 | + | for _, cmd := range r.output.Display { | |
| 14 | + | if cmd.ID == id { | |
| 15 | + | out = append(out, cmd) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | return out | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func accentRect(cmds []layout.Command, accent css.Color) (layout.Rect, bool) { | |
| 22 | + | for _, cmd := range cmds { | |
| 23 | + | if cmd.Op == "rect" && cmd.Color == accent { | |
| 24 | + | return cmd.Rect, true | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | return layout.Rect{}, false | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Checkbox and radio paint an outlined indicator when unchecked and an | |
| 31 | + | // accent-filled one when checked, inside the reserved left padding; author | |
| 32 | + | // accent-color and :checked drive them. | |
| 33 | + | func TestCheckIndicatorsFollowStateAndCSS(t *testing.T) { | |
| 34 | + | r := newRuntime() | |
| 35 | + | r.styles = css.Compile(css.UA(), css.Parse(`checkbox, radio {width:200px;height:24px;font-size:16px} checkbox:checked {accent-color:#00ff00} radio {accent-color:#ff0000}`)) | |
| 36 | + | checked, choice := false, "" | |
| 37 | + | view := func(f *Frame) { | |
| 38 | + | root := f.Root() | |
| 39 | + | root.Checkbox("Box", &checked, Key("box")) | |
| 40 | + | root.Radio("A", "a", &choice, Key("a")) | |
| 41 | + | } | |
| 42 | + | green, red := css.Color{G: 1, A: 1}, css.Color{R: 1, A: 1} | |
| 43 | + | for _, on := range []bool{false, true} { | |
| 44 | + | checked = on | |
| 45 | + | choice = map[bool]string{true: "a"}[on] | |
| 46 | + | r.Redraw() // the model changed outside NeferGUI's events | |
| 47 | + | r.Build(view) | |
| 48 | + | for id, accent := range map[string]css.Color{"root/k3:box": green, "root/k1:a": red} { | |
| 49 | + | cmds := ownCommands(r, id) | |
| 50 | + | n := resultByID(r.output.Tree, id) | |
| 51 | + | rect, filled := accentRect(cmds, accent) | |
| 52 | + | if filled != on { | |
| 53 | + | t.Fatalf("%s checked=%v: accent fill %v in %+v", id, on, filled, cmds) | |
| 54 | + | } | |
| 55 | + | if !on { | |
| 56 | + | found := false | |
| 57 | + | for _, cmd := range cmds { | |
| 58 | + | if cmd.Op == "border" && cmd.Rect.W == 16 { | |
| 59 | + | found, rect = true, cmd.Rect | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if !found { | |
| 63 | + | t.Fatalf("%s unchecked: no outlined indicator in %+v", id, cmds) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | if rect.W != 16 || rect.H != 16 || rect.X+rect.W > n.Content.X || rect.X < n.Border.X { | |
| 67 | + | t.Fatalf("%s indicator %+v outside the reserved padding (content %+v)", id, rect, n.Content) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // The slider paints a rail across the pointer track, then fill and thumb at | |
| 74 | + | // the value fraction; the thumb follows the value after a key change. | |
| 75 | + | func TestSliderTrackFollowsValue(t *testing.T) { | |
| 76 | + | r := newRuntime() | |
| 77 | + | r.styles = css.Compile(css.UA(), css.Parse(`slider {width:200px;font-size:16px;accent-color:#0000ff}`)) | |
| 78 | + | v := 2.5 | |
| 79 | + | view := func(f *Frame) { f.Root().Slider("Volume", &v, 0, 10, 0.5, Key("s")) } | |
| 80 | + | r.Build(view) | |
| 81 | + | id := "root/k1:s" | |
| 82 | + | n := resultByID(r.output.Tree, id) | |
| 83 | + | blue := css.Color{B: 1, A: 1} | |
| 84 | + | thumbX := func() float64 { | |
| 85 | + | t.Helper() | |
| 86 | + | var rects []layout.Rect | |
| 87 | + | for _, cmd := range ownCommands(r, id) { | |
| 88 | + | if cmd.Op == "rect" && cmd.Color == blue { | |
| 89 | + | rects = append(rects, cmd.Rect) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if len(rects) != 2 { | |
| 93 | + | t.Fatalf("want fill and thumb, got %+v", rects) | |
| 94 | + | } | |
| 95 | + | thumb := rects[1] | |
| 96 | + | if thumb.Y < n.Content.Y+n.Content.H || thumb.Y+thumb.H > n.Border.Y+n.Border.H { | |
| 97 | + | t.Fatalf("thumb %+v outside the reserved bottom band of %+v", thumb, n.Border) | |
| 98 | + | } | |
| 99 | + | return thumb.X + thumb.W/2 | |
| 100 | + | } | |
| 101 | + | if got, want := thumbX(), n.Content.X+0.25*n.Content.W; got != want { | |
| 102 | + | t.Fatalf("thumb center %v, want %v", got, want) | |
| 103 | + | } | |
| 104 | + | r.route(platformInput{Kind: "press", X: n.Content.X + 0.75*n.Content.W, Y: n.Content.Y + 1, Button: pointerPrimary}) | |
| 105 | + | r.Build(view) | |
| 106 | + | r.Build(view) | |
| 107 | + | if v != 7.5 { | |
| 108 | + | t.Fatalf("pointer value %v", v) | |
| 109 | + | } | |
| 110 | + | if got, want := thumbX(), n.Content.X+0.75*n.Content.W; got != want { | |
| 111 | + | t.Fatalf("thumb center after press %v, want %v", got, want) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Edge cases: a clipping control keeps its padding paint outside the content | |
| 116 | + | // clip, a slider without bottom padding paints no track, the unchecked | |
| 117 | + | // outline carries its muted alpha once, disabled checkboxes are dimmed, and | |
| 118 | + | // extreme finite ranges still give a finite thumb position. | |
| 119 | + | func TestIndicatorEdgeCases(t *testing.T) { | |
| 120 | + | r := newRuntime() | |
| 121 | + | r.styles = css.Compile(css.UA(), css.Parse(`checkbox {width:200px;height:24px;font-size:16px;overflow:hidden} slider {width:200px;padding-bottom:0}`)) | |
| 122 | + | checked, v, off := false, 1.0, false | |
| 123 | + | r.Build(func(f *Frame) { | |
| 124 | + | root := f.Root() | |
| 125 | + | root.Checkbox("Box", &checked, Key("box")) | |
| 126 | + | root.Checkbox("Off", &off, Key("off"), Disabled(true)) | |
| 127 | + | root.Slider("S", &v, 0, 10, 1, Key("s")) | |
| 128 | + | }) | |
| 129 | + | clipped, outlined := false, false | |
| 130 | + | for _, cmd := range r.output.Display { | |
| 131 | + | if cmd.ID != "root/k3:box" { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | if cmd.Op == "clip-push" { | |
| 135 | + | clipped = true | |
| 136 | + | } | |
| 137 | + | if cmd.Op == "border" && cmd.Rect.W == 16 { | |
| 138 | + | outlined = true | |
| 139 | + | if clipped { | |
| 140 | + | t.Error("indicator painted inside the content clip") | |
| 141 | + | } | |
| 142 | + | if cmd.Color.A != 1 || cmd.Colors.Top.A != 0.45 { | |
| 143 | + | t.Errorf("outline alpha color=%v side=%v, want opaque base and 0.45 sides", cmd.Color.A, cmd.Colors.Top.A) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if !clipped || !outlined { | |
| 148 | + | t.Fatalf("clipped checkbox: clip=%v indicator=%v", clipped, outlined) | |
| 149 | + | } | |
| 150 | + | dimmed := false | |
| 151 | + | for _, cmd := range ownCommands(r, "root/k3:off") { | |
| 152 | + | if cmd.Op == "border" && cmd.Rect.W == 16 { | |
| 153 | + | dimmed = true | |
| 154 | + | if cmd.Opacity != 0.5 { | |
| 155 | + | t.Errorf("disabled checkbox indicator opacity %v", cmd.Opacity) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | if !dimmed { | |
| 160 | + | t.Error("disabled checkbox indicator missing") | |
| 161 | + | } | |
| 162 | + | for _, cmd := range ownCommands(r, "root/k1:s") { | |
| 163 | + | if cmd.Op == "rect" { | |
| 164 | + | t.Errorf("slider without bottom padding painted %+v", cmd) | |
| 165 | + | break | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | for _, c := range []struct{ v, min, max, want float64 }{ | |
| 169 | + | {0, -math.MaxFloat64, math.MaxFloat64, 0.5}, | |
| 170 | + | {math.MaxFloat64, -math.MaxFloat64, math.MaxFloat64, 1}, | |
| 171 | + | {5, 5, 5, 0}, | |
| 172 | + | {2.5, 0, 10, 0.25}, | |
| 173 | + | {math.SmallestNonzeroFloat64, 0, 2 * math.SmallestNonzeroFloat64, 0.5}, | |
| 174 | + | {math.NaN(), 0, 1, 0}, | |
| 175 | + | } { | |
| 176 | + | if got := sliderFraction(c.v, c.min, c.max); got != c.want { | |
| 177 | + | t.Errorf("sliderFraction(%v,%v,%v)=%v want %v", c.v, c.min, c.max, got, c.want) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
A interaction.go +518 -0
| @@ -0,0 +1,518 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/nefergui/internal/css" | |
| 10 | + | "github.com/bnema/nefergui/internal/edit" | |
| 11 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 12 | + | "github.com/bnema/nefergui/internal/layout" | |
| 13 | + | "github.com/bnema/nefergui/internal/text" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // platformInput is the UI-loop port. Coordinates and viewport dimensions are | |
| 17 | + | // logical pixels; the platform converts physical pointer coordinates once. | |
| 18 | + | type platformInput struct { | |
| 19 | + | Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize | |
| 20 | + | X, Y, DX, DY float64 | |
| 21 | + | Width, Height, Scale float64 | |
| 22 | + | Key keyboard.Key | |
| 23 | + | Button uint32 // evdev button code (BTN_LEFT is primary) | |
| 24 | + | Shift, Ctrl bool | |
| 25 | + | IME edit.IMEBatch | |
| 26 | + | Time time.Time // optional event timestamp; zero uses receipt time | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT | |
| 30 | + | ||
| 31 | + | type interactionState struct { | |
| 32 | + | hover, capture, focus, space *identity | |
| 33 | + | button uint32 | |
| 34 | + | down, visible, inside bool | |
| 35 | + | x, y float64 | |
| 36 | + | lastTextPress time.Time | |
| 37 | + | lastTextX, lastTextY float64 | |
| 38 | + | lastTextTarget *identity | |
| 39 | + | scroll map[string]layout.Size | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (s interactionState) flags(e *element) css.StateFlags { | |
| 43 | + | var flags css.StateFlags | |
| 44 | + | if e.identity.same(s.hover) { | |
| 45 | + | flags |= css.Hover | |
| 46 | + | } | |
| 47 | + | if (e.identity.same(s.capture) && s.down && s.button == pointerPrimary) || (s.space != nil && e.identity.same(s.space)) { | |
| 48 | + | flags |= css.Active | |
| 49 | + | } | |
| 50 | + | if e.identity.same(s.focus) { | |
| 51 | + | flags |= css.Focus | |
| 52 | + | if s.visible { | |
| 53 | + | flags |= css.FocusVisible | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if e.disabled { | |
| 57 | + | flags |= css.Disabled | |
| 58 | + | } | |
| 59 | + | if e.checked { | |
| 60 | + | flags |= css.Checked | |
| 61 | + | } | |
| 62 | + | return flags | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // idKey is the stable string form of an identity path, computed once when | |
| 66 | + | // the identity is built so identities stay immutable and safe to share. | |
| 67 | + | func idKey(i *identity) string { | |
| 68 | + | if i == nil { | |
| 69 | + | return "" | |
| 70 | + | } | |
| 71 | + | if i.path == "" { | |
| 72 | + | return identityPath(i) | |
| 73 | + | } | |
| 74 | + | return i.path | |
| 75 | + | } | |
| 76 | + | func identityPath(i *identity) string { | |
| 77 | + | switch { | |
| 78 | + | case i.parent == nil: | |
| 79 | + | return "root" | |
| 80 | + | case i.explicit: | |
| 81 | + | return idKey(i.parent) + "/k" + strconv.Itoa(len(i.key)) + ":" + i.key | |
| 82 | + | default: | |
| 83 | + | return idKey(i.parent) + "/p" + strconv.Itoa(i.position) + ":" + strconv.Itoa(len(i.typ)) + ":" + i.typ | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | func layoutKind(e *element) layout.Kind { | |
| 87 | + | switch e.typ { | |
| 88 | + | case "row": | |
| 89 | + | return layout.Row | |
| 90 | + | case "column": | |
| 91 | + | return layout.Column | |
| 92 | + | case "stack": | |
| 93 | + | return layout.Stack | |
| 94 | + | case "scroll": | |
| 95 | + | return layout.Scroll | |
| 96 | + | // Labelled controls measure and paint their label like text. | |
| 97 | + | case "text", "heading", "input", "textarea", "button", "checkbox", "radio", "slider": | |
| 98 | + | return layout.Text | |
| 99 | + | case "image": | |
| 100 | + | return layout.Image | |
| 101 | + | default: | |
| 102 | + | return layout.Box | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | func (r *runtime) layoutTree(e *element) *layout.Node { | |
| 106 | + | n := &layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input"} | |
| 107 | + | if e.computed != nil { | |
| 108 | + | n.Style = e.computed.Style | |
| 109 | + | } | |
| 110 | + | if offset, ok := r.state.scroll[n.ID]; ok { | |
| 111 | + | n.ScrollX, n.ScrollY = offset.W, offset.H | |
| 112 | + | } | |
| 113 | + | if e.typ == "input" || e.typ == "textarea" { | |
| 114 | + | // Editor clips are intrinsic even if the authored CSS leaves overflow visible. | |
| 115 | + | n.EditorScroll = true | |
| 116 | + | } | |
| 117 | + | for _, child := range e.children { | |
| 118 | + | n.Children = append(n.Children, r.layoutTree(child)) | |
| 119 | + | } | |
| 120 | + | return n | |
| 121 | + | } | |
| 122 | + | func (r *runtime) lookup(id string) *element { | |
| 123 | + | var walk func(*element) *element | |
| 124 | + | walk = func(e *element) *element { | |
| 125 | + | if e == nil { | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | if idKey(e.identity) == id { | |
| 129 | + | return e | |
| 130 | + | } | |
| 131 | + | for _, child := range e.children { | |
| 132 | + | if found := walk(child); found != nil { | |
| 133 | + | return found | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | return walk(r.committed) | |
| 139 | + | } | |
| 140 | + | func (r *runtime) hit(x, y float64) *element { return r.lookup(layout.Hit(r.output.Tree, x, y)) } | |
| 141 | + | ||
| 142 | + | // cursor is the CSS cursor of the element under the pointer, from the last | |
| 143 | + | // committed styles. While a primary button is captured, the capturing element | |
| 144 | + | // keeps its cursor even if the pointer leaves it (as when dragging a slider). | |
| 145 | + | // auto and unknown values map to default. | |
| 146 | + | func (r *runtime) cursor() css.Keyword { | |
| 147 | + | r.mu.Lock() | |
| 148 | + | defer r.mu.Unlock() | |
| 149 | + | id := r.state.hover | |
| 150 | + | if r.state.down && r.state.capture != nil { | |
| 151 | + | id = r.state.capture | |
| 152 | + | } | |
| 153 | + | if !r.state.inside || id == nil { | |
| 154 | + | return css.KeywordDefault | |
| 155 | + | } | |
| 156 | + | if e := r.lookup(idKey(id)); e != nil && e.computed != nil { | |
| 157 | + | return e.computed.Style.Cursor | |
| 158 | + | } | |
| 159 | + | return css.KeywordDefault | |
| 160 | + | } | |
| 161 | + | func focusable(e *element) bool { | |
| 162 | + | if e == nil || e.disabled { | |
| 163 | + | return false | |
| 164 | + | } | |
| 165 | + | switch e.typ { | |
| 166 | + | case "button", "checkbox", "input", "radio", "slider", "textarea": | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | func (r *runtime) focusOrder() []*element { | |
| 172 | + | var result []*element | |
| 173 | + | var walk func(*layout.Result) | |
| 174 | + | walk = func(n *layout.Result) { | |
| 175 | + | if n == nil { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | e := r.lookup(n.ID) | |
| 179 | + | if focusable(e) { | |
| 180 | + | result = append(result, e) | |
| 181 | + | } | |
| 182 | + | for _, child := range n.Children { | |
| 183 | + | walk(child) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | walk(r.output.Tree) | |
| 187 | + | return result | |
| 188 | + | } | |
| 189 | + | func (r *runtime) setFocus(e *element, visible bool) bool { | |
| 190 | + | var id *identity | |
| 191 | + | if focusable(e) { | |
| 192 | + | id = e.identity | |
| 193 | + | } | |
| 194 | + | if r.state.focus.same(id) && (id == nil || r.state.visible == visible) { | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | if !r.state.focus.same(id) { | |
| 198 | + | r.state.space = nil // changing focus cancels a held Space | |
| 199 | + | } | |
| 200 | + | r.state.focus, r.state.visible = id, id != nil && visible | |
| 201 | + | return true | |
| 202 | + | } | |
| 203 | + | func (r *runtime) tab(reverse bool) bool { | |
| 204 | + | order := r.focusOrder() | |
| 205 | + | if len(order) == 0 { | |
| 206 | + | return r.setFocus(nil, false) | |
| 207 | + | } | |
| 208 | + | idx := -1 | |
| 209 | + | for i, e := range order { | |
| 210 | + | if e.identity.same(r.state.focus) { | |
| 211 | + | idx = i | |
| 212 | + | break | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | if reverse { | |
| 216 | + | if idx < 0 { | |
| 217 | + | idx = 0 | |
| 218 | + | } | |
| 219 | + | idx = (idx - 1 + len(order)) % len(order) | |
| 220 | + | } else { | |
| 221 | + | idx = (idx + 1) % len(order) | |
| 222 | + | } | |
| 223 | + | return r.setFocus(order[idx], true) | |
| 224 | + | } | |
| 225 | + | func scrollable(e *element, n *layout.Result) bool { | |
| 226 | + | if e == nil || n == nil || !n.Clipped || e.computed == nil { | |
| 227 | + | return false | |
| 228 | + | } | |
| 229 | + | st := e.computed.Style | |
| 230 | + | return e.typ == "scroll" || st.OverflowX == css.KeywordScroll || st.OverflowX == css.KeywordAuto || st.OverflowY == css.KeywordScroll || st.OverflowY == css.KeywordAuto | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // physicalToLogical is used only at the platform boundary, never for layout or Hit. | |
| 234 | + | func physicalToLogical(x, y, scale float64) (float64, float64, bool) { | |
| 235 | + | if !validPoint(x, y) || !validPoint(scale, 0) || scale <= 0 { | |
| 236 | + | return 0, 0, false | |
| 237 | + | } | |
| 238 | + | return x / scale, y / scale, true | |
| 239 | + | } | |
| 240 | + | func validPoint(x, y float64) bool { | |
| 241 | + | return !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) | |
| 242 | + | } | |
| 243 | + | func (r *runtime) route(in platformInput) { | |
| 244 | + | r.mu.Lock() | |
| 245 | + | changed := false | |
| 246 | + | s := &r.state | |
| 247 | + | queue := func(e *element, kind string) { | |
| 248 | + | if e != nil { | |
| 249 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: kind}) | |
| 250 | + | changed = true | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | switch in.Kind { | |
| 254 | + | case "resize": | |
| 255 | + | if validPoint(in.Width, in.Height) && in.Width >= 0 && in.Height >= 0 && (r.width != in.Width || r.height != in.Height) { | |
| 256 | + | r.width, r.height = in.Width, in.Height | |
| 257 | + | changed = true | |
| 258 | + | } | |
| 259 | + | if in.Scale > 0 && !math.IsInf(in.Scale, 0) && !math.IsNaN(in.Scale) && r.scale != in.Scale { | |
| 260 | + | r.scale = in.Scale | |
| 261 | + | changed = true | |
| 262 | + | } | |
| 263 | + | case "focus-out": | |
| 264 | + | if s.inside || s.hover != nil || s.down || s.capture != nil || s.space != nil { | |
| 265 | + | s.inside = false | |
| 266 | + | s.hover = nil | |
| 267 | + | s.capture = nil | |
| 268 | + | s.down = false | |
| 269 | + | s.space = nil | |
| 270 | + | changed = true | |
| 271 | + | } | |
| 272 | + | if r.setFocus(nil, false) { | |
| 273 | + | changed = true | |
| 274 | + | } | |
| 275 | + | case "focus-in": // keyboard focus returns only after an explicit traversal/click | |
| 276 | + | case "ime-done": | |
| 277 | + | if e := r.lookup(idKey(s.focus)); focusable(e) && (e.typ == "input" || e.typ == "textarea") { | |
| 278 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "ime-done", IME: in.IME}) | |
| 279 | + | changed = true | |
| 280 | + | } | |
| 281 | + | case "leave": | |
| 282 | + | // A pointer leave is not a button release. Keep capture for the | |
| 283 | + | // matching release, but do not hit-test its stale coordinates. | |
| 284 | + | if s.inside || s.hover != nil { | |
| 285 | + | s.inside = false | |
| 286 | + | s.hover = nil | |
| 287 | + | changed = true | |
| 288 | + | } | |
| 289 | + | case "motion", "press", "release", "axis": | |
| 290 | + | if !validPoint(in.X, in.Y) { | |
| 291 | + | break | |
| 292 | + | } | |
| 293 | + | // A release after leave must not turn stale in-surface coordinates | |
| 294 | + | // into a new hover or an activation. | |
| 295 | + | if in.Kind != "release" || s.inside { | |
| 296 | + | s.x, s.y = in.X, in.Y | |
| 297 | + | s.inside = true | |
| 298 | + | } | |
| 299 | + | var target *element | |
| 300 | + | if s.inside { | |
| 301 | + | target = r.hit(in.X, in.Y) | |
| 302 | + | } | |
| 303 | + | var hover *identity | |
| 304 | + | if target != nil { | |
| 305 | + | hover = target.identity | |
| 306 | + | } | |
| 307 | + | if !s.hover.same(hover) { | |
| 308 | + | s.hover = hover | |
| 309 | + | changed = true | |
| 310 | + | } | |
| 311 | + | if s.down && s.button == pointerPrimary && in.Kind == "motion" { | |
| 312 | + | if captured := r.lookup(idKey(s.capture)); focusable(captured) && (captured.typ == "input" || captured.typ == "textarea") { | |
| 313 | + | r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "edit-pointer", X: in.X, Y: in.Y, Shift: true}) | |
| 314 | + | changed = true | |
| 315 | + | } | |
| 316 | + | if captured := r.lookup(idKey(s.capture)); focusable(captured) && captured.typ == "slider" { | |
| 317 | + | r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(captured.identity)).Content}) | |
| 318 | + | changed = true | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | switch in.Kind { | |
| 322 | + | case "press": | |
| 323 | + | if !s.down { | |
| 324 | + | if in.Button != pointerPrimary || target == nil || (target.typ != "input" && target.typ != "textarea") { | |
| 325 | + | s.lastTextPress = time.Time{} | |
| 326 | + | } | |
| 327 | + | s.down = true | |
| 328 | + | s.button = in.Button | |
| 329 | + | changed = true | |
| 330 | + | if target != nil { | |
| 331 | + | s.capture = target.identity | |
| 332 | + | } | |
| 333 | + | if in.Button == pointerPrimary && r.setFocus(target, false) { | |
| 334 | + | changed = true | |
| 335 | + | } | |
| 336 | + | if in.Button == pointerPrimary && focusable(target) && (target.typ == "input" || target.typ == "textarea") { | |
| 337 | + | at := in.Time | |
| 338 | + | if at.IsZero() { | |
| 339 | + | at = time.Now() | |
| 340 | + | } | |
| 341 | + | double := target.identity.same(s.lastTextTarget) && !s.lastTextPress.IsZero() && at.Sub(s.lastTextPress) >= 0 && at.Sub(s.lastTextPress) <= 500*time.Millisecond && math.Hypot(in.X-s.lastTextX, in.Y-s.lastTextY) <= 4 | |
| 342 | + | kind := "edit-pointer" | |
| 343 | + | if double { | |
| 344 | + | kind = "edit-word" | |
| 345 | + | } | |
| 346 | + | r.pending = append(r.pending, inputEvent{Target: target.identity, Kind: kind, X: in.X, Y: in.Y, Shift: in.Shift}) | |
| 347 | + | s.lastTextPress, s.lastTextX, s.lastTextY, s.lastTextTarget = at, in.X, in.Y, target.identity | |
| 348 | + | changed = true | |
| 349 | + | } | |
| 350 | + | if in.Button == pointerPrimary && focusable(target) && target.typ == "slider" { | |
| 351 | + | r.pending = append(r.pending, inputEvent{Target: target.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(target.identity)).Content}) | |
| 352 | + | changed = true | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | case "release": | |
| 356 | + | if s.down && in.Button == s.button { | |
| 357 | + | if s.button == pointerPrimary { | |
| 358 | + | if captured := r.lookup(idKey(s.capture)); focusable(captured) && captured.typ == "slider" && s.inside { | |
| 359 | + | r.pending = append(r.pending, inputEvent{Target: captured.identity, Kind: "slider-pointer", X: in.X, Track: resultByID(r.output.Tree, idKey(captured.identity)).Content}) | |
| 360 | + | changed = true | |
| 361 | + | } | |
| 362 | + | if captured := r.lookup(idKey(s.capture)); captured != nil && captured == target && resultByID(r.output.Tree, idKey(s.capture)) != nil && focusable(captured) { | |
| 363 | + | if captured.typ == "button" || captured.typ == "checkbox" || captured.typ == "radio" { | |
| 364 | + | queue(captured, "activate") | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | s.down = false | |
| 369 | + | s.capture = nil | |
| 370 | + | changed = true | |
| 371 | + | } | |
| 372 | + | case "axis": | |
| 373 | + | if !validPoint(in.DX, in.DY) { | |
| 374 | + | break | |
| 375 | + | } | |
| 376 | + | for e := target; e != nil; e = e.parent { | |
| 377 | + | n := resultByID(r.output.Tree, idKey(e.identity)) | |
| 378 | + | if !scrollable(e, n) { | |
| 379 | + | continue | |
| 380 | + | } | |
| 381 | + | old := s.scroll[n.ID] | |
| 382 | + | next := layout.Size{W: math.Max(0, math.Min(n.ContentSize.W-n.Content.W, old.W+in.DX)), H: math.Max(0, math.Min(n.ContentSize.H-n.Content.H, old.H+in.DY))} | |
| 383 | + | if next != old { | |
| 384 | + | if s.scroll == nil { | |
| 385 | + | s.scroll = make(map[string]layout.Size) | |
| 386 | + | } | |
| 387 | + | s.scroll[n.ID] = next | |
| 388 | + | changed = true | |
| 389 | + | } | |
| 390 | + | break | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | case "key": | |
| 394 | + | name := strings.ToLower(in.Key.Name) | |
| 395 | + | if name == "space" && !in.Key.Pressed { | |
| 396 | + | if s.space != nil { | |
| 397 | + | if e := r.lookup(idKey(s.space)); e != nil && e.identity.same(s.focus) && focusable(e) && resultByID(r.output.Tree, idKey(s.space)) != nil { | |
| 398 | + | queue(e, "activate") | |
| 399 | + | } | |
| 400 | + | s.space = nil | |
| 401 | + | changed = true | |
| 402 | + | } | |
| 403 | + | break | |
| 404 | + | } | |
| 405 | + | if !in.Key.Pressed { | |
| 406 | + | break | |
| 407 | + | } | |
| 408 | + | if e := r.lookup(idKey(s.focus)); focusable(e) && (e.typ == "input" || e.typ == "textarea" || e.typ == "slider") { | |
| 409 | + | if name == "tab" || name == "iso_left_tab" { | |
| 410 | + | if !in.Key.Repeat && r.tab(in.Shift || name == "iso_left_tab") { | |
| 411 | + | changed = true | |
| 412 | + | } | |
| 413 | + | break | |
| 414 | + | } | |
| 415 | + | if e.typ == "slider" { | |
| 416 | + | switch name { | |
| 417 | + | case "left", "right", "up", "down", "home", "end", "page_up", "page_down": | |
| 418 | + | queue(e, "slider-key") | |
| 419 | + | r.pending[len(r.pending)-1].Text = name | |
| 420 | + | } | |
| 421 | + | } else { | |
| 422 | + | switch name { | |
| 423 | + | case "left", "right", "up", "down", "home", "end", "backspace", "delete": | |
| 424 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "edit-key", Text: name, Ctrl: in.Ctrl, Shift: in.Shift}) | |
| 425 | + | changed = true | |
| 426 | + | case "a", "c", "x", "v": | |
| 427 | + | if in.Ctrl && !in.Key.Repeat { | |
| 428 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "edit-key", Text: name, Ctrl: true, Shift: in.Shift}) | |
| 429 | + | changed = true | |
| 430 | + | } else if !in.Ctrl && in.Key.Text != "" && !r.composing(e.identity) { | |
| 431 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: in.Key.Text}) | |
| 432 | + | changed = true | |
| 433 | + | } | |
| 434 | + | case "return", "kp_enter": | |
| 435 | + | if e.typ == "textarea" { | |
| 436 | + | if !r.composing(e.identity) { | |
| 437 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: "\n"}) | |
| 438 | + | changed = true | |
| 439 | + | } | |
| 440 | + | } else { | |
| 441 | + | queue(e, "submit") | |
| 442 | + | } | |
| 443 | + | default: | |
| 444 | + | if in.Key.Text != "" && !in.Ctrl && !r.composing(e.identity) { | |
| 445 | + | r.pending = append(r.pending, inputEvent{Target: e.identity, Kind: "text", Text: in.Key.Text}) | |
| 446 | + | changed = true | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | break | |
| 451 | + | } | |
| 452 | + | if in.Key.Repeat { | |
| 453 | + | break | |
| 454 | + | } | |
| 455 | + | switch name { | |
| 456 | + | case "escape": | |
| 457 | + | if s.space != nil { | |
| 458 | + | s.space = nil | |
| 459 | + | changed = true | |
| 460 | + | } | |
| 461 | + | case "tab", "iso_left_tab": | |
| 462 | + | if r.tab(in.Shift || name == "iso_left_tab") { | |
| 463 | + | changed = true | |
| 464 | + | } | |
| 465 | + | case "return", "kp_enter": | |
| 466 | + | if e := r.lookup(idKey(s.focus)); focusable(e) && resultByID(r.output.Tree, idKey(s.focus)) != nil && (e.typ == "button" || e.typ == "checkbox" || e.typ == "radio") { | |
| 467 | + | queue(e, "activate") | |
| 468 | + | } | |
| 469 | + | case "space": | |
| 470 | + | if e := r.lookup(idKey(s.focus)); s.space == nil && focusable(e) && resultByID(r.output.Tree, idKey(s.focus)) != nil && (e.typ == "button" || e.typ == "checkbox" || e.typ == "radio") { | |
| 471 | + | s.space = e.identity | |
| 472 | + | changed = true | |
| 473 | + | } | |
| 474 | + | default: | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | if changed { | |
| 478 | + | r.redraw = true | |
| 479 | + | } | |
| 480 | + | r.mu.Unlock() | |
| 481 | + | if changed { | |
| 482 | + | select { | |
| 483 | + | case r.wake <- struct{}{}: | |
| 484 | + | default: | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | func resultByID(n *layout.Result, id string) *layout.Result { | |
| 489 | + | if n == nil { | |
| 490 | + | return nil | |
| 491 | + | } | |
| 492 | + | if n.ID == id { | |
| 493 | + | return n | |
| 494 | + | } | |
| 495 | + | for _, child := range n.Children { | |
| 496 | + | if found := resultByID(child, id); found != nil { | |
| 497 | + | return found | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | return nil | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // viewport and text engine are UI-loop inputs, never physical layout units. | |
| 504 | + | func (r *runtime) setTextEngine(engine *text.Engine) { | |
| 505 | + | r.mu.Lock() | |
| 506 | + | r.textEngine = engine | |
| 507 | + | r.mu.Unlock() | |
| 508 | + | r.Redraw() | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // composing reports an input-method composition in progress on the editor. | |
| 512 | + | // Keys reaching wl_keyboard were not grabbed by the input method and are typed, | |
| 513 | + | // except while a preedit is shown: a non-grabbing input method owns that text | |
| 514 | + | // until it commits, so plain keys would interleave with the composition. | |
| 515 | + | func (r *runtime) composing(id *identity) bool { | |
| 516 | + | editor := r.edits[idKey(id)] | |
| 517 | + | return editor != nil && editor.IMEActive() && editor.Preedit != "" | |
| 518 | + | } | |
A interaction_test.go +550 -0
| @@ -0,0 +1,550 @@ | |||
| 1 | + | package nefergui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefergui/internal/css" | |
| 9 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 10 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 11 | + | "github.com/bnema/nefergui/internal/text" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func key(name string) platformInput { | |
| 15 | + | return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}} | |
| 16 | + | } | |
| 17 | + | func shell(r *runtime, activated *int) func(*Frame) { | |
| 18 | + | return func(f *Frame) { | |
| 19 | + | root := f.Root() | |
| 20 | + | root.Text("Hello") | |
| 21 | + | if root.Button("Go", Key("go")).Activated() { | |
| 22 | + | *activated++ | |
| 23 | + | } | |
| 24 | + | root.Button("Disabled", Key("off"), Disabled(true)) | |
| 25 | + | root.Button("Next", Key("next")) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | func TestShellStyleAndFocus(t *testing.T) { | |
| 29 | + | r := newRuntime() | |
| 30 | + | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 35 | + | r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px;color:red} button {height:20px;background:blue} button:hover {background:green} button:focus-visible {outline:2px solid red}`)) | |
| 36 | + | var activated int | |
| 37 | + | view := shell(r, &activated) | |
| 38 | + | if !r.Build(view) { | |
| 39 | + | t.Fatal("first frame") | |
| 40 | + | } | |
| 41 | + | if len(r.output.Tree.Children) != 4 || len(r.output.Tree.Children[0].Lines) == 0 { | |
| 42 | + | t.Fatal("text shell was not shaped and laid out", r.output.Tree) | |
| 43 | + | } | |
| 44 | + | button := r.output.Tree.Children[1] | |
| 45 | + | r.route(platformInput{Kind: "motion", X: 2, Y: button.Border.Y + 2}) | |
| 46 | + | r.Build(view) | |
| 47 | + | data, err := json.Marshal(r.output.Display) | |
| 48 | + | if err != nil || !strings.Contains(string(data), `"G":0.5019607843137255`) { | |
| 49 | + | t.Fatalf("hover snapshot: %s %v", data, err) | |
| 50 | + | } | |
| 51 | + | r.route(key("Tab")) | |
| 52 | + | r.Build(view) | |
| 53 | + | data, err = json.Marshal(r.output.Display) | |
| 54 | + | if err != nil || !strings.Contains(string(data), `"Op":"outline"`) { | |
| 55 | + | t.Fatalf("focus-visible snapshot: %s %v", data, err) | |
| 56 | + | } | |
| 57 | + | if !r.state.focus.same(r.Target(1)) || !r.state.visible { | |
| 58 | + | t.Fatal("tab focus") | |
| 59 | + | } | |
| 60 | + | r.route(key("Return")) | |
| 61 | + | r.Build(view) | |
| 62 | + | if activated != 1 { | |
| 63 | + | t.Fatal("keyboard activation", activated) | |
| 64 | + | } | |
| 65 | + | r.route(key("Tab")) | |
| 66 | + | r.Build(view) | |
| 67 | + | if !r.state.focus.same(r.Target(3)) { | |
| 68 | + | t.Fatal("disabled included in tab order") | |
| 69 | + | } | |
| 70 | + | r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}}) | |
| 71 | + | r.Build(view) | |
| 72 | + | if !r.state.focus.same(r.Target(1)) { | |
| 73 | + | t.Fatal("reverse tab") | |
| 74 | + | } | |
| 75 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2}) | |
| 76 | + | r.Build(view) | |
| 77 | + | if r.state.visible { | |
| 78 | + | t.Fatal("pointer focus visible") | |
| 79 | + | } | |
| 80 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2}) | |
| 81 | + | r.Build(view) | |
| 82 | + | if activated != 2 { | |
| 83 | + | t.Fatal("pointer activation", activated) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | func TestOverlapCaptureRemovalAndScale(t *testing.T) { | |
| 87 | + | r := newRuntime() | |
| 88 | + | r.styles = css.Compile(css.UA(), css.Parse(`stack {height:30px} button {width:40px;height:20px}`)) | |
| 89 | + | front := true | |
| 90 | + | var back, top int | |
| 91 | + | view := func(f *Frame) { | |
| 92 | + | s := f.Root().Stack() | |
| 93 | + | if s.Button("back", Key("back")).Activated() { | |
| 94 | + | back++ | |
| 95 | + | } | |
| 96 | + | if front { | |
| 97 | + | if s.Button("front", Key("front")).Activated() { | |
| 98 | + | top++ | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | r.Build(view) | |
| 103 | + | for _, scale := range []float64{1.25, 1.5, 2} { | |
| 104 | + | r.route(platformInput{Kind: "resize", Width: 80, Height: 60, Scale: scale}) | |
| 105 | + | r.Build(view) | |
| 106 | + | if got := r.hit(20, 10); got == nil || got.identity.key != "front" { | |
| 107 | + | t.Fatalf("scale %v hit %+v", scale, got) | |
| 108 | + | } | |
| 109 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10}) | |
| 110 | + | r.Build(view) | |
| 111 | + | if !r.state.capture.same(r.Target(0, 1)) { | |
| 112 | + | t.Fatal("capture") | |
| 113 | + | } | |
| 114 | + | r.route(platformInput{Kind: "motion", X: 70, Y: 50}) | |
| 115 | + | r.Build(view) | |
| 116 | + | if !r.state.capture.same(r.Target(0, 1)) { | |
| 117 | + | t.Fatal("capture lost outside") | |
| 118 | + | } | |
| 119 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 70, Y: 50}) | |
| 120 | + | r.Build(view) | |
| 121 | + | if top != 0 || back != 0 { | |
| 122 | + | t.Fatal("outside release activated") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10}) | |
| 126 | + | r.Build(view) | |
| 127 | + | front = false | |
| 128 | + | r.Redraw() | |
| 129 | + | r.Build(view) | |
| 130 | + | if r.state.capture != nil || r.state.down { | |
| 131 | + | t.Fatal("removed capture retained") | |
| 132 | + | } | |
| 133 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 20, Y: 10}) | |
| 134 | + | r.Build(view) | |
| 135 | + | if top != 0 || back != 0 { | |
| 136 | + | t.Fatal("removed target activated") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | func TestFocusRemovalAndScrollClamp(t *testing.T) { | |
| 140 | + | r := newRuntime() | |
| 141 | + | r.styles = css.Compile(css.UA(), css.Parse(`scroll {width:40px;height:20px} button {height:30px}`)) | |
| 142 | + | show := true | |
| 143 | + | view := func(f *Frame) { | |
| 144 | + | root := f.Root() | |
| 145 | + | s := root.Scroll(Key("sc")) | |
| 146 | + | if show { | |
| 147 | + | s.Button("one", Key("one")) | |
| 148 | + | } | |
| 149 | + | s.Button("two", Key("two")) | |
| 150 | + | } | |
| 151 | + | r.Build(view) | |
| 152 | + | r.route(key("Tab")) | |
| 153 | + | r.Build(view) | |
| 154 | + | if !r.state.focus.same(r.Target(0, 0)) { | |
| 155 | + | t.Fatal("initial focus") | |
| 156 | + | } | |
| 157 | + | show = false | |
| 158 | + | r.Redraw() | |
| 159 | + | r.Build(view) | |
| 160 | + | if r.state.focus != nil { | |
| 161 | + | t.Fatal("focus should fall back to none") | |
| 162 | + | } | |
| 163 | + | r.route(key("Tab")) | |
| 164 | + | r.Build(view) | |
| 165 | + | if !r.state.focus.same(r.Target(0, 0)) { | |
| 166 | + | t.Fatal("tab after removal") | |
| 167 | + | } | |
| 168 | + | r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000}) | |
| 169 | + | r.Build(view) | |
| 170 | + | n := resultByID(r.output.Tree, idKey(r.Target(0))) | |
| 171 | + | if n == nil || n.ScrollY != 10 { | |
| 172 | + | t.Fatalf("scroll clamp: %+v", n) | |
| 173 | + | } | |
| 174 | + | if r.Build(view) { | |
| 175 | + | t.Fatal("idle redraw") | |
| 176 | + | } | |
| 177 | + | r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000}) | |
| 178 | + | if r.Build(view) { | |
| 179 | + | t.Fatal("clamped wheel redraw") | |
| 180 | + | } | |
| 181 | + | r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: -1000}) | |
| 182 | + | r.Build(view) | |
| 183 | + | if r.output.Tree.Children[0].ScrollY != 0 { | |
| 184 | + | t.Fatal("negative scroll clamp") | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestPointerButtonOwnership(t *testing.T) { | |
| 189 | + | r := newRuntime() | |
| 190 | + | r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`)) | |
| 191 | + | a, b := 0, 0 | |
| 192 | + | view := func(f *Frame) { | |
| 193 | + | root := f.Root() | |
| 194 | + | if root.Button("a", Key("a")).Activated() { | |
| 195 | + | a++ | |
| 196 | + | } | |
| 197 | + | if root.Button("b", Key("b")).Activated() { | |
| 198 | + | b++ | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | r.Build(view) | |
| 202 | + | pointer := func(kind string, button uint32, y float64) { | |
| 203 | + | r.route(platformInput{Kind: kind, Button: button, X: 2, Y: y}) | |
| 204 | + | } | |
| 205 | + | const secondary uint32 = 0x111 | |
| 206 | + | pointer("press", pointerPrimary, 2) | |
| 207 | + | r.Build(view) | |
| 208 | + | pointer("press", secondary, 22) | |
| 209 | + | if !r.state.capture.same(r.Target(0)) || !r.state.focus.same(r.Target(0)) { | |
| 210 | + | t.Fatal("second press stole capture/focus") | |
| 211 | + | } | |
| 212 | + | pointer("release", secondary, 22) | |
| 213 | + | if !r.state.down { | |
| 214 | + | t.Fatal("second release ended capture") | |
| 215 | + | } | |
| 216 | + | pointer("release", pointerPrimary, 2) | |
| 217 | + | r.Build(view) | |
| 218 | + | if a != 1 || b != 0 || r.state.down { | |
| 219 | + | t.Fatalf("primary completion: %d %d", a, b) | |
| 220 | + | } | |
| 221 | + | pointer("press", secondary, 22) | |
| 222 | + | r.Build(view) | |
| 223 | + | if !r.state.capture.same(r.Target(1)) || r.state.focus == nil || !r.state.focus.same(r.Target(0)) { | |
| 224 | + | t.Fatal("secondary capture changed focus") | |
| 225 | + | } | |
| 226 | + | pointer("release", pointerPrimary, 22) | |
| 227 | + | if !r.state.down { | |
| 228 | + | t.Fatal("wrong release ended secondary capture") | |
| 229 | + | } | |
| 230 | + | pointer("release", secondary, 22) | |
| 231 | + | r.Build(view) | |
| 232 | + | if b != 0 || r.state.down { | |
| 233 | + | t.Fatal("secondary activated or remained captured") | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // The cursor follows the hovered element's CSS cursor, stays on the captured | |
| 238 | + | // element while a primary button is held, and resets outside the surface. | |
| 239 | + | func TestCursorFollowsHoverAndCapture(t *testing.T) { | |
| 240 | + | r := newRuntime() | |
| 241 | + | r.styles = css.Compile(css.UA(), css.Parse(`button,input{height:20px;width:100px}`)) | |
| 242 | + | name := "" | |
| 243 | + | view := func(f *Frame) { | |
| 244 | + | root := f.Root() | |
| 245 | + | root.Button("go", Key("go")) | |
| 246 | + | root.Input("name", &name, Key("name")) | |
| 247 | + | root.Button("off", Key("off"), Disabled(true)) | |
| 248 | + | root.Text("plain", Key("plain")) | |
| 249 | + | } | |
| 250 | + | r.Build(view) | |
| 251 | + | at := func(kind string, y float64) css.Keyword { | |
| 252 | + | r.route(platformInput{Kind: kind, Button: pointerPrimary, X: 2, Y: y}) | |
| 253 | + | return r.cursor() | |
| 254 | + | } | |
| 255 | + | if c := r.cursor(); c != css.KeywordDefault { | |
| 256 | + | t.Fatalf("outside: %v", c) | |
| 257 | + | } | |
| 258 | + | for _, c := range []struct { | |
| 259 | + | y float64 | |
| 260 | + | want uint32 | |
| 261 | + | }{{2, wayland.CursorPointer}, {22, wayland.CursorText}, {42, wayland.CursorNotAllowed}, {62, wayland.CursorDefault}} { | |
| 262 | + | if got := cursorShape(at("motion", c.y)); got != c.want { | |
| 263 | + | t.Errorf("y=%v shape %v, want %v", c.y, got, c.want) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if cursorShape(css.KeywordAuto) != cursorShape(css.KeywordDefault) { | |
| 267 | + | t.Error("auto must map to the default shape") | |
| 268 | + | } | |
| 269 | + | // Press on the input, drag over the button: the input keeps its cursor. | |
| 270 | + | at("press", 22) | |
| 271 | + | r.Build(view) | |
| 272 | + | if got := at("motion", 2); got != css.KeywordText { | |
| 273 | + | t.Errorf("captured drag cursor %v", got) | |
| 274 | + | } | |
| 275 | + | at("release", 2) | |
| 276 | + | r.Build(view) | |
| 277 | + | if got := r.cursor(); got != css.KeywordPointer { | |
| 278 | + | t.Errorf("after release cursor %v", got) | |
| 279 | + | } | |
| 280 | + | r.route(platformInput{Kind: "leave"}) | |
| 281 | + | if got := r.cursor(); got != css.KeywordDefault { | |
| 282 | + | t.Errorf("after leave cursor %v", got) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // A control declared before the one whose event changes the model painted the | |
| 287 | + | // old value during the event frame. The settle frame must show the new value | |
| 288 | + | // without further input (the demo menu kept its old active item until the | |
| 289 | + | // pointer moved). | |
| 290 | + | func TestEventSettlesEarlierControls(t *testing.T) { | |
| 291 | + | r := newRuntime() | |
| 292 | + | r.styles = css.Compile(css.UA(), css.Parse(`button{height:20px} button.active{background:#ff0000}`)) | |
| 293 | + | page := "a" | |
| 294 | + | view := func(f *Frame) { | |
| 295 | + | root := f.Root() | |
| 296 | + | for _, p := range []string{"a", "b"} { | |
| 297 | + | opts := []ButtonOption{Key(p)} | |
| 298 | + | if page == p { | |
| 299 | + | opts = append(opts, Class("active")) | |
| 300 | + | } | |
| 301 | + | if root.Button(p, opts...).Activated() { | |
| 302 | + | page = p | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | active := func() string { | |
| 307 | + | for _, c := range r.output.Display { | |
| 308 | + | if c.Op == "rect" && c.Color.R == 1 && c.Color.G == 0 { | |
| 309 | + | return c.ID | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | return "" | |
| 313 | + | } | |
| 314 | + | r.Build(view) | |
| 315 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 22}) | |
| 316 | + | r.Build(view) | |
| 317 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 22}) | |
| 318 | + | r.Build(view) | |
| 319 | + | if page != "b" { | |
| 320 | + | t.Fatalf("page %q", page) | |
| 321 | + | } | |
| 322 | + | if !r.Build(view) { | |
| 323 | + | t.Fatal("no settle frame after activation") | |
| 324 | + | } | |
| 325 | + | if got := active(); got != "root/k1:b" { | |
| 326 | + | t.Fatalf("active item painted as %q after settle", got) | |
| 327 | + | } | |
| 328 | + | if r.Build(view) { | |
| 329 | + | t.Fatal("settle frame scheduled another frame") | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestKeyboardActivationLifecycle(t *testing.T) { | |
| 334 | + | r := newRuntime() | |
| 335 | + | r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px} button:active {background:red}`)) | |
| 336 | + | count := 0 | |
| 337 | + | view := func(f *Frame) { | |
| 338 | + | root := f.Root() | |
| 339 | + | if root.Button("go", Key("go")).Activated() { | |
| 340 | + | count++ | |
| 341 | + | } | |
| 342 | + | root.Button("other", Key("other")) | |
| 343 | + | } | |
| 344 | + | r.Build(view) | |
| 345 | + | r.route(key("Tab")) | |
| 346 | + | r.Build(view) | |
| 347 | + | repeat := key("Return") | |
| 348 | + | repeat.Key.Repeat = true | |
| 349 | + | r.route(key("Return")) | |
| 350 | + | r.route(repeat) | |
| 351 | + | r.Build(view) | |
| 352 | + | if count != 1 { | |
| 353 | + | t.Fatal("Enter repeat activated", count) | |
| 354 | + | } | |
| 355 | + | space := key("space") | |
| 356 | + | spaceRepeat := space | |
| 357 | + | spaceRepeat.Key.Repeat = true | |
| 358 | + | r.route(space) | |
| 359 | + | r.route(spaceRepeat) | |
| 360 | + | r.Build(view) | |
| 361 | + | if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 { | |
| 362 | + | t.Fatal("space press should only set active") | |
| 363 | + | } | |
| 364 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 365 | + | r.Build(view) | |
| 366 | + | if count != 2 || r.state.space != nil { | |
| 367 | + | t.Fatal("space release should activate once", count) | |
| 368 | + | } | |
| 369 | + | r.route(space) | |
| 370 | + | r.route(key("Escape")) | |
| 371 | + | r.Build(view) | |
| 372 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 373 | + | if r.Build(view) { | |
| 374 | + | t.Fatal("cancelled release redrew") | |
| 375 | + | } | |
| 376 | + | if count != 2 { | |
| 377 | + | t.Fatal("Escape failed to cancel") | |
| 378 | + | } | |
| 379 | + | r.route(space) | |
| 380 | + | r.route(platformInput{Kind: "focus-out"}) | |
| 381 | + | r.Build(view) | |
| 382 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 383 | + | if r.Build(view) || count != 2 { | |
| 384 | + | t.Fatal("focus loss failed to cancel") | |
| 385 | + | } | |
| 386 | + | r.route(key("Tab")) | |
| 387 | + | r.Build(view) | |
| 388 | + | r.route(space) | |
| 389 | + | r.route(key("Tab")) | |
| 390 | + | r.Build(view) | |
| 391 | + | r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}}) | |
| 392 | + | if r.Build(view) || count != 2 { | |
| 393 | + | t.Fatal("focus change failed to cancel") | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func TestScrollIdentityStopsBeingScrollable(t *testing.T) { | |
| 398 | + | r := newRuntime() | |
| 399 | + | r.styles = css.Compile(css.UA(), css.Parse(`scroll, box {width:40px;height:20px} text {height:40px}`)) | |
| 400 | + | scroll := true | |
| 401 | + | view := func(f *Frame) { | |
| 402 | + | root := f.Root() | |
| 403 | + | var n Node | |
| 404 | + | if scroll { | |
| 405 | + | n = root.Scroll(Key("same")) | |
| 406 | + | } else { | |
| 407 | + | n = root.Box(Key("same")) | |
| 408 | + | } | |
| 409 | + | n.Text("content") | |
| 410 | + | } | |
| 411 | + | r.Build(view) | |
| 412 | + | r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 15}) | |
| 413 | + | r.Build(view) | |
| 414 | + | id := idKey(r.Target(0)) | |
| 415 | + | if r.state.scroll[id].H != 15 { | |
| 416 | + | t.Fatal("scroll not stored") | |
| 417 | + | } | |
| 418 | + | scroll = false | |
| 419 | + | r.Redraw() | |
| 420 | + | r.Build(view) | |
| 421 | + | if _, ok := r.state.scroll[id]; ok { | |
| 422 | + | t.Fatal("non-scroll container retained offset") | |
| 423 | + | } | |
| 424 | + | scroll = true | |
| 425 | + | r.Redraw() | |
| 426 | + | r.Build(view) | |
| 427 | + | if r.output.Tree.Children[0].ScrollY != 0 { | |
| 428 | + | t.Fatal("stale offset restored") | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestKeyedReorderKeepsInteractionState(t *testing.T) { | |
| 433 | + | r := newRuntime() | |
| 434 | + | r.styles = css.Compile(css.UA(), css.Parse(`scroll {height:20px} button {height:30px}`)) | |
| 435 | + | order := []string{"a", "b"} | |
| 436 | + | view := func(f *Frame) { | |
| 437 | + | root := f.Root() | |
| 438 | + | for _, k := range order { | |
| 439 | + | s := root.Scroll(Key(k)) | |
| 440 | + | s.Button(k, Key("control")) | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | r.Build(view) | |
| 444 | + | r.route(platformInput{Kind: "motion", X: 2, Y: 2}) | |
| 445 | + | r.route(platformInput{Kind: "axis", X: 2, Y: 2, DY: 10}) | |
| 446 | + | r.route(key("Tab")) | |
| 447 | + | r.Build(view) | |
| 448 | + | hover, focus := r.state.hover, r.state.focus | |
| 449 | + | scrollID := idKey(r.Target(0)) | |
| 450 | + | if r.state.scroll[scrollID].H != 10 { | |
| 451 | + | t.Fatal("scroll state") | |
| 452 | + | } | |
| 453 | + | order = []string{"b", "a"} | |
| 454 | + | r.Redraw() | |
| 455 | + | r.Build(view) | |
| 456 | + | if !r.state.focus.same(focus) || r.state.scroll[scrollID].H != 10 { | |
| 457 | + | t.Fatal("keyed focus/scroll lost") | |
| 458 | + | } | |
| 459 | + | if r.state.hover.same(hover) || !r.state.hover.same(r.Target(0, 0)) { | |
| 460 | + | t.Fatal("hover should track pointer over reordered layout") | |
| 461 | + | } | |
| 462 | + | r.route(platformInput{Kind: "motion", X: 2, Y: 32}) | |
| 463 | + | r.Build(view) | |
| 464 | + | if !r.state.hover.same(hover) { | |
| 465 | + | t.Fatal("hover failed to follow keyed element at new position") | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func TestInputDuringBuildTargetsLastCommit(t *testing.T) { | |
| 470 | + | r := newRuntime() | |
| 471 | + | r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`)) | |
| 472 | + | r.Build(func(f *Frame) { f.Root().Button("old", Key("old")) }) | |
| 473 | + | r.Redraw() | |
| 474 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 475 | + | done := make(chan bool, 1) | |
| 476 | + | go func() { | |
| 477 | + | done <- r.Build(func(f *Frame) { f.Root().Button("new", Key("new")); close(entered); <-release }) | |
| 478 | + | }() | |
| 479 | + | waitEntered(t, entered) | |
| 480 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2}) | |
| 481 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2}) | |
| 482 | + | if len(r.pending) != 1 || !r.pending[0].Target.same(r.Target(0)) { | |
| 483 | + | t.Fatal("in-flight input missed last committed target") | |
| 484 | + | } | |
| 485 | + | close(release) | |
| 486 | + | if !<-done { | |
| 487 | + | t.Fatal("build failed") | |
| 488 | + | } | |
| 489 | + | received := false | |
| 490 | + | r.Build(func(f *Frame) { received = f.Root().Button("old", Key("old")).Activated() }) | |
| 491 | + | if !received { | |
| 492 | + | t.Fatal("event lost during build") | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func TestPhysicalToLogicalBoundary(t *testing.T) { | |
| 497 | + | for _, scale := range []float64{1.25, 1.5, 2} { | |
| 498 | + | x, y, ok := physicalToLogical(20*scale, 10*scale, scale) | |
| 499 | + | if !ok || x != 20 || y != 10 { | |
| 500 | + | t.Fatalf("scale %v: %v %v %v", scale, x, y, ok) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | if _, _, ok := physicalToLogical(2, 2, 0); ok { | |
| 504 | + | t.Fatal("zero scale") | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func TestPointerLeaveClearsHoverKeepsCapture(t *testing.T) { | |
| 509 | + | r := newRuntime() | |
| 510 | + | r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px;background:blue} button:hover {background:red}`)) | |
| 511 | + | activations := 0 | |
| 512 | + | view := func(f *Frame) { | |
| 513 | + | if f.Root().Button("go", Key("go")).Activated() { | |
| 514 | + | activations++ | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | r.Build(view) | |
| 518 | + | r.route(platformInput{Kind: "motion", X: 2, Y: 2}) | |
| 519 | + | r.Build(view) | |
| 520 | + | if !r.state.inside || !r.state.hover.same(r.Target(0)) || r.committed.children[0].computed.Style.BackgroundColor.R != 1 { | |
| 521 | + | t.Fatal("hover not applied") | |
| 522 | + | } | |
| 523 | + | r.route(platformInput{Kind: "leave"}) | |
| 524 | + | r.Build(view) | |
| 525 | + | if r.state.inside || r.state.hover != nil || r.committed.children[0].computed.Style.BackgroundColor.B != 1 { | |
| 526 | + | t.Fatal("leave did not clear hover style") | |
| 527 | + | } | |
| 528 | + | r.route(platformInput{Kind: "leave"}) | |
| 529 | + | if r.Build(view) { | |
| 530 | + | t.Fatal("duplicate leave invalidated") | |
| 531 | + | } | |
| 532 | + | r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2}) | |
| 533 | + | r.Build(view) | |
| 534 | + | if !r.state.down || !r.state.capture.same(r.Target(0)) { | |
| 535 | + | t.Fatal("no capture") | |
| 536 | + | } | |
| 537 | + | r.route(platformInput{Kind: "leave"}) | |
| 538 | + | r.Build(view) | |
| 539 | + | if r.state.inside || r.state.hover != nil || !r.state.down || !r.state.capture.same(r.Target(0)) { | |
| 540 | + | t.Fatal("leave cancelled capture or kept hover") | |
| 541 | + | } | |
| 542 | + | r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2}) | |
| 543 | + | r.Build(view) | |
| 544 | + | if activations != 0 || r.state.down || r.state.capture != nil || r.state.inside || r.state.hover != nil { | |
| 545 | + | t.Fatal("outside release activated or kept capture/hover") | |
| 546 | + | } | |
| 547 | + | if r.Build(view) { | |
| 548 | + | t.Fatal("idle redraw after leave/release") | |
| 549 | + | } | |
| 550 | + | } | |
A internal/bidi/LICENSE +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | Copyright 2009 The Go Authors. | |
| 2 | + | ||
| 3 | + | Redistribution and use in source and binary forms, with or without | |
| 4 | + | modification, are permitted provided that the following conditions are | |
| 5 | + | met: | |
| 6 | + | ||
| 7 | + | * Redistributions of source code must retain the above copyright | |
| 8 | + | notice, this list of conditions and the following disclaimer. | |
| 9 | + | * Redistributions in binary form must reproduce the above | |
| 10 | + | copyright notice, this list of conditions and the following disclaimer | |
| 11 | + | in the documentation and/or other materials provided with the | |
| 12 | + | distribution. | |
| 13 | + | * Neither the name of Google LLC nor the names of its | |
| 14 | + | contributors may be used to endorse or promote products derived from | |
| 15 | + | this software without specific prior written permission. | |
| 16 | + | ||
| 17 | + | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 18 | + | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 19 | + | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 20 | + | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 21 | + | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 22 | + | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 23 | + | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 24 | + | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 25 | + | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 26 | + | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 27 | + | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
A internal/bidi/SOURCE.md +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | # Provenance of vendored bidi implementation | |
| 2 | + | ||
| 3 | + | Source: `golang.org/x/text@v0.42.0/unicode/bidi` (BSD-3-Clause; `LICENSE`). Copied unchanged except, in `bidi.go`, the package import comment and the upstream `go:generate` directive (its generators are not vendored), both marked `// nefergui:`. `levels.go` and `conformance_test.go` are NeferGUI additions; all modifications/extensions are marked `// nefergui:`. Upstream generator and generated-table tests depend on `golang.org/x/text/internal/*` (inaccessible from NeferGUI); they are deliberately not copied. Upstream `bidi_test.go` is retained and passing. Tables selected by Go 1.27 use Unicode **17.0.0**; `tables15.0.0.go` is retained for the original build tags. Go 1.27 is the supported module version. | |
| 4 | + | ||
| 5 | + | SHA-256 of each original copied file (before the marked package-comment modification): | |
| 6 | + | ||
| 7 | + | | file | SHA-256 | | |
| 8 | + | | --- | --- | | |
| 9 | + | | bidi.go | b1e9360e9a026006f56914a5716b18780346418bb0b64bce23a4ff92d8147b13 | | |
| 10 | + | | bidi_test.go | 2e01617e4fc2f617bfd0246852c398525ae28616d05bf3c1d28d8c0dcb574d87 | | |
| 11 | + | | bracket.go | 5caa24f9c4e04a54d18875468f976b4b7c09c8700e88286f2fd96828f134107e | | |
| 12 | + | | core.go | ef15872f0cac7702bba67bbba334c4fc85376869e18fadec40e646f1ba8c4493 | | |
| 13 | + | | prop.go | f6391b2f69a1ae2a0ac1f0599a90b260ec350a19f73ad5d6572660100b5950bc | | |
| 14 | + | | trieval.go | bf3e17fca178c7d13139aa7cb7828005a0a1b8f4dca8ef97966236c6469a2136 | | |
| 15 | + | | tables15.0.0.go | 98c2d6d57e116667e73a4f5c89ee20ead7b27b50d561b5c5d23d8ebf0e3f3310 | | |
| 16 | + | | tables17.0.0.go | a49eafd4eb11f7c8ae810ee5d69ca59a0cddd97dc644afbcb3e40563617088fc | | |
| 17 | + | | LICENSE | 911f8f5782931320f5b8d1160a76365b83aea6447ee6c04fa6d5591467db9dad | | |
| 18 | + | ||
| 19 | + | Unicode 17 `BidiCharacterTest.txt`: https://www.unicode.org/Public/17.0.0/ucd/BidiCharacterTest.txt; SHA-256 `a3e6e905ab5afbe318a96df5401d0372a04cd73ef139ab5e3cf0ae241c255488`. Terms: https://www.unicode.org/license.txt (copied as `testdata/LICENSE`; SHA-256 `e7a93b009565cfce55919a381437ac4db883e9da2126fa28b91d12732bc53d96`). All 91,707 cases must pass for paragraph level, resolved L1 levels, and L2 order. NeferGUI's wrapper canonicalizes the U+2329/U+232A bracket identifiers to U+3008 before calling the upstream bracket pairer (4 otherwise failing canonical-equivalence cases); the upstream algorithm is otherwise unchanged. | |
A internal/bidi/bidi.go +359 -0
| @@ -0,0 +1,359 @@ | |||
| 1 | + | // Copyright 2015 The Go Authors. All rights reserved. | |
| 2 | + | // Use of this source code is governed by a BSD-style | |
| 3 | + | // license that can be found in the LICENSE file. | |
| 4 | + | ||
| 5 | + | // nefergui: upstream go:generate directive removed; its generators are not vendored (see SOURCE.md). | |
| 6 | + | ||
| 7 | + | // Package bidi contains functionality for bidirectional text support. | |
| 8 | + | // | |
| 9 | + | // See https://www.unicode.org/reports/tr9. | |
| 10 | + | // | |
| 11 | + | // NOTE: UNDER CONSTRUCTION. This API may change in backwards incompatible ways | |
| 12 | + | // and without notice. | |
| 13 | + | package bidi // nefergui: local vendored copy of x/text unicode/bidi | |
| 14 | + | ||
| 15 | + | // TODO | |
| 16 | + | // - Transformer for reordering? | |
| 17 | + | // - Transformer (validator, really) for Bidi Rule. | |
| 18 | + | ||
| 19 | + | import ( | |
| 20 | + | "bytes" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // This API tries to avoid dealing with embedding levels for now. Under the hood | |
| 24 | + | // these will be computed, but the question is to which extent the user should | |
| 25 | + | // know they exist. We should at some point allow the user to specify an | |
| 26 | + | // embedding hierarchy, though. | |
| 27 | + | ||
| 28 | + | // A Direction indicates the overall flow of text. | |
| 29 | + | type Direction int | |
| 30 | + | ||
| 31 | + | const ( | |
| 32 | + | // LeftToRight indicates the text contains no right-to-left characters and | |
| 33 | + | // that either there are some left-to-right characters or the option | |
| 34 | + | // DefaultDirection(LeftToRight) was passed. | |
| 35 | + | LeftToRight Direction = iota | |
| 36 | + | ||
| 37 | + | // RightToLeft indicates the text contains no left-to-right characters and | |
| 38 | + | // that either there are some right-to-left characters or the option | |
| 39 | + | // DefaultDirection(RightToLeft) was passed. | |
| 40 | + | RightToLeft | |
| 41 | + | ||
| 42 | + | // Mixed indicates text contains both left-to-right and right-to-left | |
| 43 | + | // characters. | |
| 44 | + | Mixed | |
| 45 | + | ||
| 46 | + | // Neutral means that text contains no left-to-right and right-to-left | |
| 47 | + | // characters and that no default direction has been set. | |
| 48 | + | Neutral | |
| 49 | + | ) | |
| 50 | + | ||
| 51 | + | type options struct { | |
| 52 | + | defaultDirection Direction | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // An Option is an option for Bidi processing. | |
| 56 | + | type Option func(*options) | |
| 57 | + | ||
| 58 | + | // ICU allows the user to define embedding levels. This may be used, for example, | |
| 59 | + | // to use hierarchical structure of markup languages to define embeddings. | |
| 60 | + | // The following option may be a way to expose this functionality in this API. | |
| 61 | + | // // LevelFunc sets a function that associates nesting levels with the given text. | |
| 62 | + | // // The levels function will be called with monotonically increasing values for p. | |
| 63 | + | // func LevelFunc(levels func(p int) int) Option { | |
| 64 | + | // panic("unimplemented") | |
| 65 | + | // } | |
| 66 | + | ||
| 67 | + | // DefaultDirection sets the default direction for a Paragraph. The direction is | |
| 68 | + | // overridden if the text contains directional characters. | |
| 69 | + | func DefaultDirection(d Direction) Option { | |
| 70 | + | return func(opts *options) { | |
| 71 | + | opts.defaultDirection = d | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // A Paragraph holds a single Paragraph for Bidi processing. | |
| 76 | + | type Paragraph struct { | |
| 77 | + | p []byte | |
| 78 | + | o Ordering | |
| 79 | + | opts []Option | |
| 80 | + | types []Class | |
| 81 | + | pairTypes []bracketType | |
| 82 | + | pairValues []rune | |
| 83 | + | runes []rune | |
| 84 | + | options options | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Initialize the p.pairTypes, p.pairValues and p.types from the input previously | |
| 88 | + | // set by p.SetBytes() or p.SetString(). Also limit the input up to (and including) a paragraph | |
| 89 | + | // separator (bidi class B). | |
| 90 | + | // | |
| 91 | + | // The function p.Order() needs these values to be set, so this preparation could be postponed. | |
| 92 | + | // But since the SetBytes and SetStrings functions return the length of the input up to the paragraph | |
| 93 | + | // separator, the whole input needs to be processed anyway and should not be done twice. | |
| 94 | + | // | |
| 95 | + | // The function has the same return values as SetBytes() / SetString() | |
| 96 | + | func (p *Paragraph) prepareInput() (n int, err error) { | |
| 97 | + | p.runes = bytes.Runes(p.p) | |
| 98 | + | bytecount := 0 | |
| 99 | + | // clear slices from previous SetString or SetBytes | |
| 100 | + | p.pairTypes = nil | |
| 101 | + | p.pairValues = nil | |
| 102 | + | p.types = nil | |
| 103 | + | ||
| 104 | + | for _, r := range p.runes { | |
| 105 | + | props, i := LookupRune(r) | |
| 106 | + | bytecount += i | |
| 107 | + | cls := props.Class() | |
| 108 | + | if cls == B { | |
| 109 | + | return bytecount, nil | |
| 110 | + | } | |
| 111 | + | p.types = append(p.types, cls) | |
| 112 | + | if props.IsOpeningBracket() { | |
| 113 | + | p.pairTypes = append(p.pairTypes, bpOpen) | |
| 114 | + | p.pairValues = append(p.pairValues, r) | |
| 115 | + | } else if props.IsBracket() { | |
| 116 | + | // this must be a closing bracket, | |
| 117 | + | // since IsOpeningBracket is not true | |
| 118 | + | p.pairTypes = append(p.pairTypes, bpClose) | |
| 119 | + | p.pairValues = append(p.pairValues, r) | |
| 120 | + | } else { | |
| 121 | + | p.pairTypes = append(p.pairTypes, bpNone) | |
| 122 | + | p.pairValues = append(p.pairValues, 0) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return bytecount, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // SetBytes configures p for the given paragraph text. It replaces text | |
| 129 | + | // previously set by SetBytes or SetString. If b contains a paragraph separator | |
| 130 | + | // it will only process the first paragraph and report the number of bytes | |
| 131 | + | // consumed from b including this separator. Error may be non-nil if options are | |
| 132 | + | // given. | |
| 133 | + | func (p *Paragraph) SetBytes(b []byte, opts ...Option) (n int, err error) { | |
| 134 | + | p.p = b | |
| 135 | + | p.opts = opts | |
| 136 | + | return p.prepareInput() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SetString configures s for the given paragraph text. It replaces text | |
| 140 | + | // previously set by SetBytes or SetString. If s contains a paragraph separator | |
| 141 | + | // it will only process the first paragraph and report the number of bytes | |
| 142 | + | // consumed from s including this separator. Error may be non-nil if options are | |
| 143 | + | // given. | |
| 144 | + | func (p *Paragraph) SetString(s string, opts ...Option) (n int, err error) { | |
| 145 | + | p.p = []byte(s) | |
| 146 | + | p.opts = opts | |
| 147 | + | return p.prepareInput() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // IsLeftToRight reports whether the principle direction of rendering for this | |
| 151 | + | // paragraphs is left-to-right. If this returns false, the principle direction | |
| 152 | + | // of rendering is right-to-left. | |
| 153 | + | func (p *Paragraph) IsLeftToRight() bool { | |
| 154 | + | return p.Direction() == LeftToRight | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Direction returns the direction of the text of this paragraph. | |
| 158 | + | // | |
| 159 | + | // The direction may be LeftToRight, RightToLeft, Mixed, or Neutral. | |
| 160 | + | func (p *Paragraph) Direction() Direction { | |
| 161 | + | return p.o.Direction() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // TODO: what happens if the position is > len(input)? This should return an error. | |
| 165 | + | ||
| 166 | + | // RunAt reports the Run at the given position of the input text. | |
| 167 | + | // | |
| 168 | + | // This method can be used for computing line breaks on paragraphs. | |
| 169 | + | func (p *Paragraph) RunAt(pos int) Run { | |
| 170 | + | c := 0 | |
| 171 | + | runNumber := 0 | |
| 172 | + | for i, r := range p.o.runes { | |
| 173 | + | c += len(r) | |
| 174 | + | if pos < c { | |
| 175 | + | runNumber = i | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return p.o.Run(runNumber) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func calculateOrdering(levels []level, runes []rune) Ordering { | |
| 182 | + | var curDir Direction | |
| 183 | + | ||
| 184 | + | prevDir := Neutral | |
| 185 | + | prevI := 0 | |
| 186 | + | ||
| 187 | + | o := Ordering{} | |
| 188 | + | // lvl = 0,2,4,...: left to right | |
| 189 | + | // lvl = 1,3,5,...: right to left | |
| 190 | + | for i, lvl := range levels { | |
| 191 | + | if lvl%2 == 0 { | |
| 192 | + | curDir = LeftToRight | |
| 193 | + | } else { | |
| 194 | + | curDir = RightToLeft | |
| 195 | + | } | |
| 196 | + | if curDir != prevDir { | |
| 197 | + | if i > 0 { | |
| 198 | + | o.runes = append(o.runes, runes[prevI:i]) | |
| 199 | + | o.directions = append(o.directions, prevDir) | |
| 200 | + | o.startpos = append(o.startpos, prevI) | |
| 201 | + | } | |
| 202 | + | prevI = i | |
| 203 | + | prevDir = curDir | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | o.runes = append(o.runes, runes[prevI:]) | |
| 207 | + | o.directions = append(o.directions, prevDir) | |
| 208 | + | o.startpos = append(o.startpos, prevI) | |
| 209 | + | return o | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Order computes the visual ordering of all the runs in a Paragraph. | |
| 213 | + | func (p *Paragraph) Order() (Ordering, error) { | |
| 214 | + | if len(p.types) == 0 { | |
| 215 | + | return Ordering{}, nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | for _, fn := range p.opts { | |
| 219 | + | fn(&p.options) | |
| 220 | + | } | |
| 221 | + | lvl := level(-1) | |
| 222 | + | if p.options.defaultDirection == RightToLeft { | |
| 223 | + | lvl = 1 | |
| 224 | + | } | |
| 225 | + | para, err := newParagraph(p.types, p.pairTypes, p.pairValues, lvl) | |
| 226 | + | if err != nil { | |
| 227 | + | return Ordering{}, err | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | levels := para.getLevels([]int{len(p.types)}) | |
| 231 | + | ||
| 232 | + | p.o = calculateOrdering(levels, p.runes) | |
| 233 | + | return p.o, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // Line computes the visual ordering of runs for a single line starting and | |
| 237 | + | // ending at the given positions in the original text. | |
| 238 | + | func (p *Paragraph) Line(start, end int) (Ordering, error) { | |
| 239 | + | lineTypes := p.types[start:end] | |
| 240 | + | para, err := newParagraph(lineTypes, p.pairTypes[start:end], p.pairValues[start:end], -1) | |
| 241 | + | if err != nil { | |
| 242 | + | return Ordering{}, err | |
| 243 | + | } | |
| 244 | + | levels := para.getLevels([]int{len(lineTypes)}) | |
| 245 | + | o := calculateOrdering(levels, p.runes[start:end]) | |
| 246 | + | return o, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // An Ordering holds the computed visual order of runs of a Paragraph. Calling | |
| 250 | + | // SetBytes or SetString on the originating Paragraph invalidates an Ordering. | |
| 251 | + | // The methods of an Ordering should only be called by one goroutine at a time. | |
| 252 | + | type Ordering struct { | |
| 253 | + | runes [][]rune | |
| 254 | + | directions []Direction | |
| 255 | + | startpos []int | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // Direction reports the directionality of the runs. | |
| 259 | + | // | |
| 260 | + | // The direction may be LeftToRight, RightToLeft, Mixed, or Neutral. | |
| 261 | + | func (o *Ordering) Direction() Direction { | |
| 262 | + | return o.directions[0] | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // NumRuns returns the number of runs. | |
| 266 | + | func (o *Ordering) NumRuns() int { | |
| 267 | + | return len(o.runes) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Run returns the ith run within the ordering. | |
| 271 | + | func (o *Ordering) Run(i int) Run { | |
| 272 | + | r := Run{ | |
| 273 | + | runes: o.runes[i], | |
| 274 | + | direction: o.directions[i], | |
| 275 | + | startpos: o.startpos[i], | |
| 276 | + | } | |
| 277 | + | return r | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // TODO: perhaps with options. | |
| 281 | + | // // Reorder creates a reader that reads the runes in visual order per character. | |
| 282 | + | // // Modifiers remain after the runes they modify. | |
| 283 | + | // func (l *Runs) Reorder() io.Reader { | |
| 284 | + | // panic("unimplemented") | |
| 285 | + | // } | |
| 286 | + | ||
| 287 | + | // A Run is a continuous sequence of characters of a single direction. | |
| 288 | + | type Run struct { | |
| 289 | + | runes []rune | |
| 290 | + | direction Direction | |
| 291 | + | startpos int | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // String returns the text of the run in its original order. | |
| 295 | + | func (r *Run) String() string { | |
| 296 | + | return string(r.runes) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // Bytes returns the text of the run in its original order. | |
| 300 | + | func (r *Run) Bytes() []byte { | |
| 301 | + | return []byte(r.String()) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // TODO: methods for | |
| 305 | + | // - Display order | |
| 306 | + | // - headers and footers | |
| 307 | + | // - bracket replacement. | |
| 308 | + | ||
| 309 | + | // Direction reports the direction of the run. | |
| 310 | + | func (r *Run) Direction() Direction { | |
| 311 | + | return r.direction | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // Pos returns the position of the Run within the text passed to SetBytes or SetString of the | |
| 315 | + | // originating Paragraph value. | |
| 316 | + | func (r *Run) Pos() (start, end int) { | |
| 317 | + | return r.startpos, r.startpos + len(r.runes) - 1 | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // AppendReverse reverses the order of characters of in, appends them to out, | |
| 321 | + | // and returns the result. Modifiers will still follow the runes they modify. | |
| 322 | + | // Brackets are replaced with their counterparts. | |
| 323 | + | func AppendReverse(out, in []byte) []byte { | |
| 324 | + | ret := make([]byte, len(in)+len(out)) | |
| 325 | + | copy(ret, out) | |
| 326 | + | inRunes := bytes.Runes(in) | |
| 327 | + | ||
| 328 | + | for i, r := range inRunes { | |
| 329 | + | prop, _ := LookupRune(r) | |
| 330 | + | if prop.IsBracket() { | |
| 331 | + | inRunes[i] = prop.reverseBracket(r) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | for i, j := 0, len(inRunes)-1; i < j; i, j = i+1, j-1 { | |
| 336 | + | inRunes[i], inRunes[j] = inRunes[j], inRunes[i] | |
| 337 | + | } | |
| 338 | + | copy(ret[len(out):], string(inRunes)) | |
| 339 | + | ||
| 340 | + | return ret | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // ReverseString reverses the order of characters in s and returns a new string. | |
| 344 | + | // Modifiers will still follow the runes they modify. Brackets are replaced with | |
| 345 | + | // their counterparts. | |
| 346 | + | func ReverseString(s string) string { | |
| 347 | + | input := []rune(s) | |
| 348 | + | li := len(input) | |
| 349 | + | ret := make([]rune, li) | |
| 350 | + | for i, r := range input { | |
| 351 | + | prop, _ := LookupRune(r) | |
| 352 | + | if prop.IsBracket() { | |
| 353 | + | ret[li-i-1] = prop.reverseBracket(r) | |
| 354 | + | } else { | |
| 355 | + | ret[li-i-1] = r | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | return string(ret) | |
| 359 | + | } | |
A internal/bidi/bidi_test.go +347 -0
| @@ -0,0 +1,347 @@ | |||
| 1 | + | package bidi | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type runInformation struct { | |
| 9 | + | str string | |
| 10 | + | dir Direction | |
| 11 | + | start int | |
| 12 | + | end int | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestSimple(t *testing.T) { | |
| 16 | + | str := "Hellö" | |
| 17 | + | p := Paragraph{} | |
| 18 | + | p.SetString(str) | |
| 19 | + | order, err := p.Order() | |
| 20 | + | if err != nil { | |
| 21 | + | log.Fatal(err) | |
| 22 | + | } | |
| 23 | + | expectedRuns := []runInformation{ | |
| 24 | + | {"Hellö", LeftToRight, 0, 4}, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | if !p.IsLeftToRight() { | |
| 28 | + | t.Error("p.IsLeftToRight() == false; want true") | |
| 29 | + | } | |
| 30 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 31 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 32 | + | } | |
| 33 | + | for i, want := range expectedRuns { | |
| 34 | + | r := order.Run(i) | |
| 35 | + | if got := r.String(); got != want.str { | |
| 36 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 37 | + | } | |
| 38 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 39 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start %d, end %d", i, s, e, want.start, want.end) | |
| 40 | + | } | |
| 41 | + | if d := r.Direction(); d != want.dir { | |
| 42 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestMixed(t *testing.T) { | |
| 48 | + | str := `العاشر ليونيكود (Unicode Conference)، الذي سيعقد في 10-12 آذار 1997 مبدينة` | |
| 49 | + | p := Paragraph{} | |
| 50 | + | p.SetString(str) | |
| 51 | + | order, err := p.Order() | |
| 52 | + | if err != nil { | |
| 53 | + | log.Fatal(err) | |
| 54 | + | } | |
| 55 | + | if p.IsLeftToRight() { | |
| 56 | + | t.Error("p.IsLeftToRight() == true; want false") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | expectedRuns := []runInformation{ | |
| 60 | + | {"العاشر ليونيكود (", RightToLeft, 0, 16}, | |
| 61 | + | {"Unicode Conference", LeftToRight, 17, 34}, | |
| 62 | + | {")، الذي سيعقد في ", RightToLeft, 35, 51}, | |
| 63 | + | {"10", LeftToRight, 52, 53}, | |
| 64 | + | {"-", RightToLeft, 54, 54}, | |
| 65 | + | {"12", LeftToRight, 55, 56}, | |
| 66 | + | {" آذار ", RightToLeft, 57, 62}, | |
| 67 | + | {"1997", LeftToRight, 63, 66}, | |
| 68 | + | {" مبدينة", RightToLeft, 67, 73}, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 72 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | for i, want := range expectedRuns { | |
| 76 | + | r := order.Run(i) | |
| 77 | + | if got := r.String(); got != want.str { | |
| 78 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 79 | + | } | |
| 80 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 81 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start = %d, end = %d", i, s, e, want.start, want.end) | |
| 82 | + | } | |
| 83 | + | if d := r.Direction(); d != want.dir { | |
| 84 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestExplicitIsolate(t *testing.T) { | |
| 90 | + | // https://www.w3.org/International/articles/inline-bidi-markup/uba-basics.en#beyond | |
| 91 | + | str := "The names of these states in Arabic are \u2067مصر\u2069, \u2067البحرين\u2069 and \u2067الكويت\u2069 respectively." | |
| 92 | + | p := Paragraph{} | |
| 93 | + | p.SetString(str) | |
| 94 | + | order, err := p.Order() | |
| 95 | + | if err != nil { | |
| 96 | + | log.Fatal(err) | |
| 97 | + | } | |
| 98 | + | if !p.IsLeftToRight() { | |
| 99 | + | t.Error("p.IsLeftToRight() == false; want true") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | expectedRuns := []runInformation{ | |
| 103 | + | {"The names of these states in Arabic are \u2067", LeftToRight, 0, 40}, | |
| 104 | + | {"مصر", RightToLeft, 41, 43}, | |
| 105 | + | {"\u2069, \u2067", LeftToRight, 44, 47}, | |
| 106 | + | {"البحرين", RightToLeft, 48, 54}, | |
| 107 | + | {"\u2069 and \u2067", LeftToRight, 55, 61}, | |
| 108 | + | {"الكويت", RightToLeft, 62, 67}, | |
| 109 | + | {"\u2069 respectively.", LeftToRight, 68, 82}, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 113 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | for i, want := range expectedRuns { | |
| 117 | + | r := order.Run(i) | |
| 118 | + | if got := r.String(); got != want.str { | |
| 119 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 120 | + | } | |
| 121 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 122 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start = %d, end = %d", i, s, e, want.start, want.end) | |
| 123 | + | } | |
| 124 | + | if d := r.Direction(); d != want.dir { | |
| 125 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestWithoutExplicitIsolate(t *testing.T) { | |
| 131 | + | str := "The names of these states in Arabic are مصر, البحرين and الكويت respectively." | |
| 132 | + | p := Paragraph{} | |
| 133 | + | p.SetString(str) | |
| 134 | + | order, err := p.Order() | |
| 135 | + | if err != nil { | |
| 136 | + | log.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if !p.IsLeftToRight() { | |
| 139 | + | t.Error("p.IsLeftToRight() == false; want true") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | expectedRuns := []runInformation{ | |
| 143 | + | {"The names of these states in Arabic are ", LeftToRight, 0, 39}, | |
| 144 | + | {"مصر, البحرين", RightToLeft, 40, 51}, | |
| 145 | + | {" and ", LeftToRight, 52, 56}, | |
| 146 | + | {"الكويت", RightToLeft, 57, 62}, | |
| 147 | + | {" respectively.", LeftToRight, 63, 76}, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 151 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | for i, want := range expectedRuns { | |
| 155 | + | r := order.Run(i) | |
| 156 | + | if got := r.String(); got != want.str { | |
| 157 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 158 | + | } | |
| 159 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 160 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start = %d, end = %d", i, s, e, want.start, want.end) | |
| 161 | + | } | |
| 162 | + | if d := r.Direction(); d != want.dir { | |
| 163 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestLongUTF8(t *testing.T) { | |
| 169 | + | str := `𠀀` | |
| 170 | + | p := Paragraph{} | |
| 171 | + | p.SetString(str) | |
| 172 | + | order, err := p.Order() | |
| 173 | + | if err != nil { | |
| 174 | + | log.Fatal(err) | |
| 175 | + | } | |
| 176 | + | if !p.IsLeftToRight() { | |
| 177 | + | t.Error("p.IsLeftToRight() == false; want true") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | expectedRuns := []runInformation{ | |
| 181 | + | {"𠀀", LeftToRight, 0, 0}, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 185 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | for i, want := range expectedRuns { | |
| 189 | + | r := order.Run(i) | |
| 190 | + | if got := r.String(); got != want.str { | |
| 191 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 192 | + | } | |
| 193 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 194 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start = %d, end = %d", i, s, e, want.start, want.end) | |
| 195 | + | } | |
| 196 | + | if d := r.Direction(); d != want.dir { | |
| 197 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestLLongUTF8(t *testing.T) { | |
| 203 | + | strTester := []struct { | |
| 204 | + | str string | |
| 205 | + | l int | |
| 206 | + | }{ | |
| 207 | + | {"ö", 2}, | |
| 208 | + | {"ॡ", 3}, | |
| 209 | + | {`𠀀`, 4}, | |
| 210 | + | } | |
| 211 | + | for _, st := range strTester { | |
| 212 | + | str := st.str | |
| 213 | + | want := st.l | |
| 214 | + | if _, l := LookupString(str); l != want { | |
| 215 | + | t.Errorf("LookupString(%q) length = %d; want %d", str, l, want) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | } | |
| 219 | + | ||
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestMixedSimple(t *testing.T) { | |
| 223 | + | str := `Uا` | |
| 224 | + | p := Paragraph{} | |
| 225 | + | p.SetString(str) | |
| 226 | + | order, err := p.Order() | |
| 227 | + | if err != nil { | |
| 228 | + | log.Fatal(err) | |
| 229 | + | } | |
| 230 | + | if !p.IsLeftToRight() { | |
| 231 | + | t.Error("p.IsLeftToRight() == false; want true") | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | expectedRuns := []runInformation{ | |
| 235 | + | {"U", LeftToRight, 0, 0}, | |
| 236 | + | {"ا", RightToLeft, 1, 1}, | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | if nr, want := order.NumRuns(), len(expectedRuns); nr != want { | |
| 240 | + | t.Errorf("order.NumRuns() = %d; want %d", nr, want) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | for i, want := range expectedRuns { | |
| 244 | + | r := order.Run(i) | |
| 245 | + | if got := r.String(); got != want.str { | |
| 246 | + | t.Errorf("Run(%d) = %q; want %q", i, got, want.str) | |
| 247 | + | } | |
| 248 | + | if s, e := r.Pos(); s != want.start || e != want.end { | |
| 249 | + | t.Errorf("Run(%d).start = %d, .end = %d; want start = %d, end = %d", i, s, e, want.start, want.end) | |
| 250 | + | } | |
| 251 | + | if d := r.Direction(); d != want.dir { | |
| 252 | + | t.Errorf("Run(%d).Direction = %d; want %d", i, d, want.dir) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestDefaultDirection(t *testing.T) { | |
| 258 | + | str := "+" | |
| 259 | + | p := Paragraph{} | |
| 260 | + | p.SetString(str, DefaultDirection(RightToLeft)) | |
| 261 | + | _, err := p.Order() | |
| 262 | + | if err != nil { | |
| 263 | + | t.Error(err) | |
| 264 | + | t.Fail() | |
| 265 | + | } | |
| 266 | + | if want, dir := false, p.IsLeftToRight(); want != dir { | |
| 267 | + | t.Errorf("p.IsLeftToRight() = %t; want %t", dir, want) | |
| 268 | + | } | |
| 269 | + | p.SetString(str, DefaultDirection(LeftToRight)) | |
| 270 | + | _, err = p.Order() | |
| 271 | + | if err != nil { | |
| 272 | + | t.Error(err) | |
| 273 | + | t.Fail() | |
| 274 | + | } | |
| 275 | + | if want, dir := true, p.IsLeftToRight(); want != dir { | |
| 276 | + | t.Errorf("p.IsLeftToRight() = %t; want %t", dir, want) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestEmpty(t *testing.T) { | |
| 282 | + | p := Paragraph{} | |
| 283 | + | p.SetBytes([]byte{}) | |
| 284 | + | o, err := p.Order() | |
| 285 | + | if err != nil { | |
| 286 | + | t.Error("p.Order() return err != nil; want err == nil") | |
| 287 | + | } | |
| 288 | + | if nr := o.NumRuns(); nr != 0 { | |
| 289 | + | t.Errorf("o.NumRuns() = %d; want 0", nr) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestNewline(t *testing.T) { | |
| 294 | + | str := "Hello\nworld" | |
| 295 | + | p := Paragraph{} | |
| 296 | + | n, err := p.SetString(str) | |
| 297 | + | if err != nil { | |
| 298 | + | t.Error(err) | |
| 299 | + | } | |
| 300 | + | // 6 is the length up to and including the \n | |
| 301 | + | if want := 6; n != want { | |
| 302 | + | t.Errorf("SetString(%q) = nil, %d; want nil, %d", str, n, want) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestDoubleSetString(t *testing.T) { | |
| 307 | + | str := "العاشر ليونيكود (Unicode Conference)،" | |
| 308 | + | p := Paragraph{} | |
| 309 | + | _, err := p.SetString(str) | |
| 310 | + | if err != nil { | |
| 311 | + | t.Error(err) | |
| 312 | + | } | |
| 313 | + | _, err = p.SetString(str) | |
| 314 | + | if err != nil { | |
| 315 | + | t.Error(err) | |
| 316 | + | } | |
| 317 | + | _, err = p.Order() | |
| 318 | + | if err != nil { | |
| 319 | + | t.Error(err) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func TestReverseString(t *testing.T) { | |
| 324 | + | input := "(Hello)" | |
| 325 | + | want := "(olleH)" | |
| 326 | + | if str := ReverseString(input); str != want { | |
| 327 | + | t.Errorf("ReverseString(%s) = %q; want %q", input, str, want) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func TestAppendReverse(t *testing.T) { | |
| 332 | + | testcase := []struct { | |
| 333 | + | inString string | |
| 334 | + | outString string | |
| 335 | + | want string | |
| 336 | + | }{ | |
| 337 | + | {"", "Hëllo", "Hëllo"}, | |
| 338 | + | {"nice (wörld)", "", "(dlröw) ecin"}, | |
| 339 | + | {"nice (wörld)", "Hëllo", "Hëllo(dlröw) ecin"}, | |
| 340 | + | } | |
| 341 | + | for _, tc := range testcase { | |
| 342 | + | if r := AppendReverse([]byte(tc.outString), []byte(tc.inString)); string(r) != tc.want { | |
| 343 | + | t.Errorf("AppendReverse([]byte(%q), []byte(%q) = %q; want %q", tc.outString, tc.inString, string(r), tc.want) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | } | |
A internal/bidi/bracket.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | // Copyright 2015 The Go Authors. All rights reserved. | |
| 2 | + | // Use of this source code is governed by a BSD-style | |
| 3 | + | // license that can be found in the LICENSE file. | |
| 4 | + | ||
| 5 | + | package bidi | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "container/list" | |
| 9 | + | "fmt" | |
| 10 | + | "sort" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // This file contains a port of the reference implementation of the | |
| 14 | + | // Bidi Parentheses Algorithm: | |
| 15 | + | // https://www.unicode.org/Public/PROGRAMS/BidiReferenceJava/BidiPBAReference.java | |
| 16 | + | // | |
| 17 | + | // The implementation in this file covers definitions BD14-BD16 and rule N0 | |
| 18 | + | // of UAX#9. | |
| 19 | + | // | |
| 20 | + | // Some preprocessing is done for each rune before data is passed to this | |
| 21 | + | // algorithm: | |
| 22 | + | // - opening and closing brackets are identified | |
| 23 | + | // - a bracket pair type, like '(' and ')' is assigned a unique identifier that | |
| 24 | + | // is identical for the opening and closing bracket. It is left to do these | |
| 25 | + | // mappings. | |
| 26 | + | // - The BPA algorithm requires that bracket characters that are canonical | |
| 27 | + | // equivalents of each other be able to be substituted for each other. | |
| 28 | + | // It is the responsibility of the caller to do this canonicalization. | |
| 29 | + | // | |
| 30 | + | // In implementing BD16, this implementation departs slightly from the "logical" | |
| 31 | + | // algorithm defined in UAX#9. In particular, the stack referenced there | |
| 32 | + | // supports operations that go beyond a "basic" stack. An equivalent | |
| 33 | + | // implementation based on a linked list is used here. | |
| 34 | + | ||
| 35 | + | // Bidi_Paired_Bracket_Type | |
| 36 | + | // BD14. An opening paired bracket is a character whose | |
| 37 | + | // Bidi_Paired_Bracket_Type property value is Open. | |
| 38 | + | // | |
| 39 | + | // BD15. A closing paired bracket is a character whose | |
| 40 | + | // Bidi_Paired_Bracket_Type property value is Close. | |
| 41 | + | type bracketType byte | |
| 42 | + | ||
| 43 | + | const ( | |
| 44 | + | bpNone bracketType = iota | |
| 45 | + | bpOpen | |
| 46 | + | bpClose | |
| 47 | + | ) | |
| 48 | + | ||
| 49 | + | // bracketPair holds a pair of index values for opening and closing bracket | |
| 50 | + | // location of a bracket pair. | |
| 51 | + | type bracketPair struct { | |
| 52 | + | opener int | |
| 53 | + | closer int | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (b *bracketPair) String() string { | |
| 57 | + | return fmt.Sprintf("(%v, %v)", b.opener, b.closer) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // bracketPairs is a slice of bracketPairs with a sort.Interface implementation. | |
| 61 | + | type bracketPairs []bracketPair | |
| 62 | + | ||
| 63 | + | func (b bracketPairs) Len() int { return len(b) } | |
| 64 | + | func (b bracketPairs) Swap(i, j int) { b[i], b[j] = b[j], b[i] } | |
| 65 | + | func (b bracketPairs) Less(i, j int) bool { return b[i].opener < b[j].opener } | |
| 66 | + | ||
| 67 | + | // resolvePairedBrackets runs the paired bracket part of the UBA algorithm. | |
| 68 | + | // | |
| 69 | + | // For each rune, it takes the indexes into the original string, the class the | |
| 70 | + | // bracket type (in pairTypes) and the bracket identifier (pairValues). It also | |
| 71 | + | // takes the direction type for the start-of-sentence and the embedding level. | |
| 72 | + | // | |
| 73 | + | // The identifiers for bracket types are the rune of the canonicalized opening | |
| 74 | + | // bracket for brackets (open or close) or 0 for runes that are not brackets. | |
| 75 | + | func resolvePairedBrackets(s *isolatingRunSequence) { | |
| 76 | + | p := bracketPairer{ | |
| 77 | + | sos: s.sos, | |
| 78 | + | openers: list.New(), | |
| 79 | + | codesIsolatedRun: s.types, | |
| 80 | + | indexes: s.indexes, | |
| 81 | + | } | |
| 82 | + | dirEmbed := L | |
| 83 | + | if s.level&1 != 0 { | |
| 84 | + | dirEmbed = R | |
| 85 | + | } | |
| 86 | + | p.locateBrackets(s.p.pairTypes, s.p.pairValues) | |
| 87 | + | p.resolveBrackets(dirEmbed, s.p.initialTypes) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type bracketPairer struct { | |
| 91 | + | sos Class // direction corresponding to start of sequence | |
| 92 | + | ||
| 93 | + | // The following is a restatement of BD 16 using non-algorithmic language. | |
| 94 | + | // | |
| 95 | + | // A bracket pair is a pair of characters consisting of an opening | |
| 96 | + | // paired bracket and a closing paired bracket such that the | |
| 97 | + | // Bidi_Paired_Bracket property value of the former equals the latter, | |
| 98 | + | // subject to the following constraints. | |
| 99 | + | // - both characters of a pair occur in the same isolating run sequence | |
| 100 | + | // - the closing character of a pair follows the opening character | |
| 101 | + | // - any bracket character can belong at most to one pair, the earliest possible one | |
| 102 | + | // - any bracket character not part of a pair is treated like an ordinary character | |
| 103 | + | // - pairs may nest properly, but their spans may not overlap otherwise | |
| 104 | + | ||
| 105 | + | // Bracket characters with canonical decompositions are supposed to be | |
| 106 | + | // treated as if they had been normalized, to allow normalized and non- | |
| 107 | + | // normalized text to give the same result. In this implementation that step | |
| 108 | + | // is pushed out to the caller. The caller has to ensure that the pairValue | |
| 109 | + | // slices contain the rune of the opening bracket after normalization for | |
| 110 | + | // any opening or closing bracket. | |
| 111 | + | ||
| 112 | + | openers *list.List // list of positions for opening brackets | |
| 113 | + | ||
| 114 | + | // bracket pair positions sorted by location of opening bracket | |
| 115 | + | pairPositions bracketPairs | |
| 116 | + | ||
| 117 | + | codesIsolatedRun []Class // directional bidi codes for an isolated run | |
| 118 | + | indexes []int // array of index values into the original string | |
| 119 | + | ||
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // matchOpener reports whether characters at given positions form a matching | |
| 123 | + | // bracket pair. | |
| 124 | + | func (p *bracketPairer) matchOpener(pairValues []rune, opener, closer int) bool { | |
| 125 | + | return pairValues[p.indexes[opener]] == pairValues[p.indexes[closer]] | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | const maxPairingDepth = 63 | |
| 129 | + | ||
| 130 | + | // locateBrackets locates matching bracket pairs according to BD16. | |
| 131 | + | // | |
| 132 | + | // This implementation uses a linked list instead of a stack, because, while | |
| 133 | + | // elements are added at the front (like a push) they are not generally removed | |
| 134 | + | // in atomic 'pop' operations, reducing the benefit of the stack archetype. | |
| 135 | + | func (p *bracketPairer) locateBrackets(pairTypes []bracketType, pairValues []rune) { | |
| 136 | + | // traverse the run | |
| 137 | + | // do that explicitly (not in a for-each) so we can record position | |
| 138 | + | for i, index := range p.indexes { | |
| 139 | + | ||
| 140 | + | // look at the bracket type for each character | |
| 141 | + | if pairTypes[index] == bpNone || p.codesIsolatedRun[i] != ON { | |
| 142 | + | // continue scanning | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | switch pairTypes[index] { | |
| 146 | + | case bpOpen: | |
| 147 | + | // check if maximum pairing depth reached | |
| 148 | + | if p.openers.Len() == maxPairingDepth { | |
| 149 | + | p.openers.Init() | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | // remember opener location, most recent first | |
| 153 | + | p.openers.PushFront(i) | |
| 154 | + | ||
| 155 | + | case bpClose: | |
| 156 | + | // see if there is a match | |
| 157 | + | count := 0 | |
| 158 | + | for elem := p.openers.Front(); elem != nil; elem = elem.Next() { | |
| 159 | + | count++ | |
| 160 | + | opener := elem.Value.(int) | |
| 161 | + | if p.matchOpener(pairValues, opener, i) { | |
| 162 | + | // if the opener matches, add nested pair to the ordered list | |
| 163 | + | p.pairPositions = append(p.pairPositions, bracketPair{opener, i}) | |
| 164 | + | // remove up to and including matched opener | |
| 165 | + | for ; count > 0; count-- { | |
| 166 | + | p.openers.Remove(p.openers.Front()) | |
| 167 | + | } | |
| 168 | + | break | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | sort.Sort(p.pairPositions) | |
| 172 | + | // if we get here, the closing bracket matched no openers | |
| 173 | + | // and gets ignored | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Bracket pairs within an isolating run sequence are processed as units so | |
| 179 | + | // that both the opening and the closing paired bracket in a pair resolve to | |
| 180 | + | // the same direction. | |
| 181 | + | // | |
| 182 | + | // N0. Process bracket pairs in an isolating run sequence sequentially in | |
| 183 | + | // the logical order of the text positions of the opening paired brackets | |
| 184 | + | // using the logic given below. Within this scope, bidirectional types EN | |
| 185 | + | // and AN are treated as R. | |
| 186 | + | // | |
| 187 | + | // Identify the bracket pairs in the current isolating run sequence | |
| 188 | + | // according to BD16. For each bracket-pair element in the list of pairs of | |
| 189 | + | // text positions: | |
| 190 | + | // | |
| 191 | + | // a Inspect the bidirectional types of the characters enclosed within the | |
| 192 | + | // bracket pair. | |
| 193 | + | // | |
| 194 | + | // b If any strong type (either L or R) matching the embedding direction is | |
| 195 | + | // found, set the type for both brackets in the pair to match the embedding | |
| 196 | + | // direction. | |
| 197 | + | // | |
| 198 | + | // o [ e ] o -> o e e e o | |
| 199 | + | // | |
| 200 | + | // o [ o e ] -> o e o e e | |
| 201 | + | // | |
| 202 | + | // o [ NI e ] -> o e NI e e | |
| 203 | + | // | |
| 204 | + | // c Otherwise, if a strong type (opposite the embedding direction) is | |
| 205 | + | // found, test for adjacent strong types as follows: 1 First, check | |
| 206 | + | // backwards before the opening paired bracket until the first strong type | |
| 207 | + | // (L, R, or sos) is found. If that first preceding strong type is opposite | |
| 208 | + | // the embedding direction, then set the type for both brackets in the pair | |
| 209 | + | // to that type. 2 Otherwise, set the type for both brackets in the pair to | |
| 210 | + | // the embedding direction. | |
| 211 | + | // | |
| 212 | + | // o [ o ] e -> o o o o e | |
| 213 | + | // | |
| 214 | + | // o [ o NI ] o -> o o o NI o o | |
| 215 | + | // | |
| 216 | + | // e [ o ] o -> e e o e o | |
| 217 | + | // | |
| 218 | + | // e [ o ] e -> e e o e e | |
| 219 | + | // | |
| 220 | + | // e ( o [ o ] NI ) e -> e e o o o o NI e e | |
| 221 | + | // | |
| 222 | + | // d Otherwise, do not set the type for the current bracket pair. Note that | |
| 223 | + | // if the enclosed text contains no strong types the paired brackets will | |
| 224 | + | // both resolve to the same level when resolved individually using rules N1 | |
| 225 | + | // and N2. | |
| 226 | + | // | |
| 227 | + | // e ( NI ) o -> e ( NI ) o | |
| 228 | + | ||
| 229 | + | // getStrongTypeN0 maps character's directional code to strong type as required | |
| 230 | + | // by rule N0. | |
| 231 | + | // | |
| 232 | + | // TODO: have separate type for "strong" directionality. | |
| 233 | + | func (p *bracketPairer) getStrongTypeN0(index int) Class { | |
| 234 | + | switch p.codesIsolatedRun[index] { | |
| 235 | + | // in the scope of N0, number types are treated as R | |
| 236 | + | case EN, AN, AL, R: | |
| 237 | + | return R | |
| 238 | + | case L: | |
| 239 | + | return L | |
| 240 | + | default: | |
| 241 | + | return ON | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // classifyPairContent reports the strong types contained inside a Bracket Pair, | |
| 246 | + | // assuming the given embedding direction. | |
| 247 | + | // | |
| 248 | + | // It returns ON if no strong type is found. If a single strong type is found, | |
| 249 | + | // it returns this type. Otherwise it returns the embedding direction. | |
| 250 | + | // | |
| 251 | + | // TODO: use separate type for "strong" directionality. | |
| 252 | + | func (p *bracketPairer) classifyPairContent(loc bracketPair, dirEmbed Class) Class { | |
| 253 | + | dirOpposite := ON | |
| 254 | + | for i := loc.opener + 1; i < loc.closer; i++ { | |
| 255 | + | dir := p.getStrongTypeN0(i) | |
| 256 | + | if dir == ON { | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | if dir == dirEmbed { | |
| 260 | + | return dir // type matching embedding direction found | |
| 261 | + | } | |
| 262 | + | dirOpposite = dir | |
| 263 | + | } | |
| 264 | + | // return ON if no strong type found, or class opposite to dirEmbed | |
| 265 | + | return dirOpposite | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // classBeforePair determines which strong types are present before a Bracket | |
| 269 | + | // Pair. Return R or L if strong type found, otherwise ON. | |
| 270 | + | func (p *bracketPairer) classBeforePair(loc bracketPair) Class { | |
| 271 | + | for i := loc.opener - 1; i >= 0; i-- { | |
| 272 | + | if dir := p.getStrongTypeN0(i); dir != ON { | |
| 273 | + | return dir | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | // no strong types found, return sos | |
| 277 | + | return p.sos | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // assignBracketType implements rule N0 for a single bracket pair. | |
| 281 | + | func (p *bracketPairer) assignBracketType(loc bracketPair, dirEmbed Class, initialTypes []Class) { | |
| 282 | + | // rule "N0, a", inspect contents of pair | |
| 283 | + | dirPair := p.classifyPairContent(loc, dirEmbed) | |
| 284 | + | ||
| 285 | + | // dirPair is now L, R, or N (no strong type found) | |
| 286 | + | ||
| 287 | + | // the following logical tests are performed out of order compared to | |
| 288 | + | // the statement of the rules but yield the same results | |
| 289 | + | if dirPair == ON { | |
| 290 | + | return // case "d" - nothing to do | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | if dirPair != dirEmbed { | |
| 294 | + | // case "c": strong type found, opposite - check before (c.1) | |
| 295 | + | dirPair = p.classBeforePair(loc) | |
| 296 | + | if dirPair == dirEmbed || dirPair == ON { | |
| 297 | + | // no strong opposite type found before - use embedding (c.2) | |
| 298 | + | dirPair = dirEmbed | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | // else: case "b", strong type found matching embedding, | |
| 302 | + | // no explicit action needed, as dirPair is already set to embedding | |
| 303 | + | // direction | |
| 304 | + | ||
| 305 | + | // set the bracket types to the type found | |
| 306 | + | p.setBracketsToType(loc, dirPair, initialTypes) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (p *bracketPairer) setBracketsToType(loc bracketPair, dirPair Class, initialTypes []Class) { | |
| 310 | + | p.codesIsolatedRun[loc.opener] = dirPair | |
| 311 | + | p.codesIsolatedRun[loc.closer] = dirPair | |
| 312 | + | ||
| 313 | + | for i := loc.opener + 1; i < loc.closer; i++ { | |
| 314 | + | index := p.indexes[i] | |
| 315 | + | if initialTypes[index] != NSM { | |
| 316 | + | break | |
| 317 | + | } | |
| 318 | + | p.codesIsolatedRun[i] = dirPair | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | for i := loc.closer + 1; i < len(p.indexes); i++ { | |
| 322 | + | index := p.indexes[i] | |
| 323 | + | if initialTypes[index] != NSM { | |
| 324 | + | break | |
| 325 | + | } | |
| 326 | + | p.codesIsolatedRun[i] = dirPair | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // resolveBrackets implements rule N0 for a list of pairs. | |
| 331 | + | func (p *bracketPairer) resolveBrackets(dirEmbed Class, initialTypes []Class) { | |
| 332 | + | for _, loc := range p.pairPositions { | |
| 333 | + | p.assignBracketType(loc, dirEmbed, initialTypes) | |
| 334 | + | } | |
| 335 | + | } | |
A internal/bidi/conformance_test.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package bidi | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "reflect" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // nefergui: verify pinned Unicode 17 BidiCharacterTest paragraph level, L1 | |
| 14 | + | // character levels and L2 visual order against the full upstream corpus. | |
| 15 | + | func TestBidiCharacterConformance(t *testing.T) { | |
| 16 | + | f, err := os.Open("testdata/BidiCharacterTest.txt") | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | defer f.Close() | |
| 21 | + | scan := bufio.NewScanner(f) | |
| 22 | + | scan.Buffer(make([]byte, 4096), 2<<20) | |
| 23 | + | lineNo, failures, cases := 0, 0, 0 | |
| 24 | + | for scan.Scan() { | |
| 25 | + | lineNo++ | |
| 26 | + | s := strings.TrimSpace(scan.Text()) | |
| 27 | + | if s == "" || s[0] == '#' { | |
| 28 | + | continue | |
| 29 | + | } | |
| 30 | + | parts := strings.Split(s, ";") | |
| 31 | + | if len(parts) != 5 { | |
| 32 | + | t.Fatalf("line %d: fields %d", lineNo, len(parts)) | |
| 33 | + | } | |
| 34 | + | text := make([]rune, 0) | |
| 35 | + | for _, hex := range strings.Fields(parts[0]) { | |
| 36 | + | n, e := strconv.ParseUint(hex, 16, 32) | |
| 37 | + | if e != nil { | |
| 38 | + | t.Fatal(e) | |
| 39 | + | } | |
| 40 | + | text = append(text, rune(n)) | |
| 41 | + | } | |
| 42 | + | mode, e := strconv.Atoi(parts[1]) | |
| 43 | + | if e != nil || mode < 0 || mode > 2 { | |
| 44 | + | t.Fatalf("line %d: base %q", lineNo, parts[1]) | |
| 45 | + | } | |
| 46 | + | base := Auto | |
| 47 | + | if mode == 0 { | |
| 48 | + | base = LTR | |
| 49 | + | } else if mode == 1 { | |
| 50 | + | base = RTL | |
| 51 | + | } | |
| 52 | + | breaks := []int{len(text)} | |
| 53 | + | levels, para, e := Resolve(text, base, breaks) | |
| 54 | + | if e != nil { | |
| 55 | + | t.Fatalf("line %d: %v", lineNo, e) | |
| 56 | + | } | |
| 57 | + | expectedPara, e := strconv.Atoi(parts[2]) | |
| 58 | + | if e != nil { | |
| 59 | + | t.Fatal(e) | |
| 60 | + | } | |
| 61 | + | var expectedLevels []uint8 | |
| 62 | + | for _, token := range strings.Fields(parts[3]) { | |
| 63 | + | if token == "x" { | |
| 64 | + | expectedLevels = append(expectedLevels, 255) | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | n, e := strconv.Atoi(token) | |
| 68 | + | if e != nil { | |
| 69 | + | t.Fatal(e) | |
| 70 | + | } | |
| 71 | + | expectedLevels = append(expectedLevels, uint8(n)) | |
| 72 | + | } | |
| 73 | + | order, e := Reorder(text, levels, breaks) | |
| 74 | + | if e != nil { | |
| 75 | + | t.Fatal(e) | |
| 76 | + | } | |
| 77 | + | var expectedOrder []int | |
| 78 | + | for _, token := range strings.Fields(parts[4]) { | |
| 79 | + | n, e := strconv.Atoi(token) | |
| 80 | + | if e != nil { | |
| 81 | + | t.Fatal(e) | |
| 82 | + | } | |
| 83 | + | expectedOrder = append(expectedOrder, n) | |
| 84 | + | } | |
| 85 | + | if len(expectedOrder) == 0 { | |
| 86 | + | expectedOrder = []int{} | |
| 87 | + | } | |
| 88 | + | if len(order) != 1 { | |
| 89 | + | t.Fatal("missing line") | |
| 90 | + | } | |
| 91 | + | if int(para) != expectedPara || len(levels) != len(expectedLevels) || !reflect.DeepEqual(order[0], expectedOrder) || !matchLevels(text, levels, expectedLevels) { | |
| 92 | + | failures++ | |
| 93 | + | if failures <= 10 { | |
| 94 | + | t.Errorf("line %d: para %d/%d levels %v/%v order %v/%v", lineNo, para, expectedPara, levels, expectedLevels, order[0], expectedOrder) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | cases++ | |
| 98 | + | } | |
| 99 | + | if err := scan.Err(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | if failures > 0 { | |
| 103 | + | t.Fatalf("%d/%d conformance failures", failures, cases) | |
| 104 | + | } | |
| 105 | + | t.Logf("%d Unicode 17 BidiCharacterTest cases", cases) | |
| 106 | + | } | |
| 107 | + | func matchLevels(text []rune, got, want []uint8) bool { | |
| 108 | + | if len(got) != len(want) { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | for i, n := range want { | |
| 112 | + | if n == 255 { | |
| 113 | + | p, _ := LookupRune(text[i]) | |
| 114 | + | if !isRemovedByX9(p.Class()) { | |
| 115 | + | panic(fmt.Sprintf("non-X9 x at %d", i)) | |
| 116 | + | } | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | if got[i] != n { | |
| 120 | + | return false | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return true | |
| 124 | + | } | |
A internal/bidi/core.go +1065 -0
| @@ -0,0 +1,1065 @@ | |||
| 1 | + | // Copyright 2015 The Go Authors. All rights reserved. | |
| 2 | + | // Use of this source code is governed by a BSD-style | |
| 3 | + | // license that can be found in the LICENSE file. | |
| 4 | + | ||
| 5 | + | package bidi | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "fmt" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // This implementation is a port based on the reference implementation found at: | |
| 12 | + | // https://www.unicode.org/Public/PROGRAMS/BidiReferenceJava/ | |
| 13 | + | // | |
| 14 | + | // described in Unicode Bidirectional Algorithm (UAX #9). | |
| 15 | + | // | |
| 16 | + | // Input: | |
| 17 | + | // There are two levels of input to the algorithm, since clients may prefer to | |
| 18 | + | // supply some information from out-of-band sources rather than relying on the | |
| 19 | + | // default behavior. | |
| 20 | + | // | |
| 21 | + | // - Bidi class array | |
| 22 | + | // - Bidi class array, with externally supplied base line direction | |
| 23 | + | // | |
| 24 | + | // Output: | |
| 25 | + | // Output is separated into several stages: | |
| 26 | + | // | |
| 27 | + | // - levels array over entire paragraph | |
| 28 | + | // - reordering array over entire paragraph | |
| 29 | + | // - levels array over line | |
| 30 | + | // - reordering array over line | |
| 31 | + | // | |
| 32 | + | // Note that for conformance to the Unicode Bidirectional Algorithm, | |
| 33 | + | // implementations are only required to generate correct reordering and | |
| 34 | + | // character directionality (odd or even levels) over a line. Generating | |
| 35 | + | // identical level arrays over a line is not required. Bidi explicit format | |
| 36 | + | // codes (LRE, RLE, LRO, RLO, PDF) and BN can be assigned arbitrary levels and | |
| 37 | + | // positions as long as the rest of the input is properly reordered. | |
| 38 | + | // | |
| 39 | + | // As the algorithm is defined to operate on a single paragraph at a time, this | |
| 40 | + | // implementation is written to handle single paragraphs. Thus rule P1 is | |
| 41 | + | // presumed by this implementation-- the data provided to the implementation is | |
| 42 | + | // assumed to be a single paragraph, and either contains no 'B' codes, or a | |
| 43 | + | // single 'B' code at the end of the input. 'B' is allowed as input to | |
| 44 | + | // illustrate how the algorithm assigns it a level. | |
| 45 | + | // | |
| 46 | + | // Also note that rules L3 and L4 depend on the rendering engine that uses the | |
| 47 | + | // result of the bidi algorithm. This implementation assumes that the rendering | |
| 48 | + | // engine expects combining marks in visual order (e.g. to the left of their | |
| 49 | + | // base character in RTL runs) and that it adjusts the glyphs used to render | |
| 50 | + | // mirrored characters that are in RTL runs so that they render appropriately. | |
| 51 | + | ||
| 52 | + | // level is the embedding level of a character. Even embedding levels indicate | |
| 53 | + | // left-to-right order and odd levels indicate right-to-left order. The special | |
| 54 | + | // level of -1 is reserved for undefined order. | |
| 55 | + | type level int8 | |
| 56 | + | ||
| 57 | + | const implicitLevel level = -1 | |
| 58 | + | ||
| 59 | + | // in returns if x is equal to any of the values in set. | |
| 60 | + | func (c Class) in(set ...Class) bool { | |
| 61 | + | for _, s := range set { | |
| 62 | + | if c == s { | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // A paragraph contains the state of a paragraph. | |
| 70 | + | type paragraph struct { | |
| 71 | + | initialTypes []Class | |
| 72 | + | ||
| 73 | + | // Arrays of properties needed for paired bracket evaluation in N0 | |
| 74 | + | pairTypes []bracketType // paired Bracket types for paragraph | |
| 75 | + | pairValues []rune // rune for opening bracket or pbOpen and pbClose; 0 for pbNone | |
| 76 | + | ||
| 77 | + | embeddingLevel level // default: = implicitLevel; | |
| 78 | + | ||
| 79 | + | // at the paragraph levels | |
| 80 | + | resultTypes []Class | |
| 81 | + | resultLevels []level | |
| 82 | + | ||
| 83 | + | // Index of matching PDI for isolate initiator characters. For other | |
| 84 | + | // characters, the value of matchingPDI will be set to -1. For isolate | |
| 85 | + | // initiators with no matching PDI, matchingPDI will be set to the length of | |
| 86 | + | // the input string. | |
| 87 | + | matchingPDI []int | |
| 88 | + | ||
| 89 | + | // Index of matching isolate initiator for PDI characters. For other | |
| 90 | + | // characters, and for PDIs with no matching isolate initiator, the value of | |
| 91 | + | // matchingIsolateInitiator will be set to -1. | |
| 92 | + | matchingIsolateInitiator []int | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // newParagraph initializes a paragraph. The user needs to supply a few arrays | |
| 96 | + | // corresponding to the preprocessed text input. The types correspond to the | |
| 97 | + | // Unicode BiDi classes for each rune. pairTypes indicates the bracket type for | |
| 98 | + | // each rune. pairValues provides a unique bracket class identifier for each | |
| 99 | + | // rune (suggested is the rune of the open bracket for opening and matching | |
| 100 | + | // close brackets, after normalization). The embedding levels are optional, but | |
| 101 | + | // may be supplied to encode embedding levels of styled text. | |
| 102 | + | func newParagraph(types []Class, pairTypes []bracketType, pairValues []rune, levels level) (*paragraph, error) { | |
| 103 | + | var err error | |
| 104 | + | if err = validateTypes(types); err != nil { | |
| 105 | + | return nil, err | |
| 106 | + | } | |
| 107 | + | if err = validatePbTypes(pairTypes); err != nil { | |
| 108 | + | return nil, err | |
| 109 | + | } | |
| 110 | + | if err = validatePbValues(pairValues, pairTypes); err != nil { | |
| 111 | + | return nil, err | |
| 112 | + | } | |
| 113 | + | if err = validateParagraphEmbeddingLevel(levels); err != nil { | |
| 114 | + | return nil, err | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | p := ¶graph{ | |
| 118 | + | initialTypes: append([]Class(nil), types...), | |
| 119 | + | embeddingLevel: levels, | |
| 120 | + | ||
| 121 | + | pairTypes: pairTypes, | |
| 122 | + | pairValues: pairValues, | |
| 123 | + | ||
| 124 | + | resultTypes: append([]Class(nil), types...), | |
| 125 | + | } | |
| 126 | + | p.run() | |
| 127 | + | return p, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (p *paragraph) Len() int { return len(p.initialTypes) } | |
| 131 | + | ||
| 132 | + | // The algorithm. Does not include line-based processing (Rules L1, L2). | |
| 133 | + | // These are applied later in the line-based phase of the algorithm. | |
| 134 | + | func (p *paragraph) run() { | |
| 135 | + | p.determineMatchingIsolates() | |
| 136 | + | ||
| 137 | + | // 1) determining the paragraph level | |
| 138 | + | // Rule P1 is the requirement for entering this algorithm. | |
| 139 | + | // Rules P2, P3. | |
| 140 | + | // If no externally supplied paragraph embedding level, use default. | |
| 141 | + | if p.embeddingLevel == implicitLevel { | |
| 142 | + | p.embeddingLevel = p.determineParagraphEmbeddingLevel(0, p.Len()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Initialize result levels to paragraph embedding level. | |
| 146 | + | p.resultLevels = make([]level, p.Len()) | |
| 147 | + | setLevels(p.resultLevels, p.embeddingLevel) | |
| 148 | + | ||
| 149 | + | // 2) Explicit levels and directions | |
| 150 | + | // Rules X1-X8. | |
| 151 | + | p.determineExplicitEmbeddingLevels() | |
| 152 | + | ||
| 153 | + | // Rule X9. | |
| 154 | + | // We do not remove the embeddings, the overrides, the PDFs, and the BNs | |
| 155 | + | // from the string explicitly. But they are not copied into isolating run | |
| 156 | + | // sequences when they are created, so they are removed for all | |
| 157 | + | // practical purposes. | |
| 158 | + | ||
| 159 | + | // Rule X10. | |
| 160 | + | // Run remainder of algorithm one isolating run sequence at a time | |
| 161 | + | for _, seq := range p.determineIsolatingRunSequences() { | |
| 162 | + | // 3) resolving weak types | |
| 163 | + | // Rules W1-W7. | |
| 164 | + | seq.resolveWeakTypes() | |
| 165 | + | ||
| 166 | + | // 4a) resolving paired brackets | |
| 167 | + | // Rule N0 | |
| 168 | + | resolvePairedBrackets(seq) | |
| 169 | + | ||
| 170 | + | // 4b) resolving neutral types | |
| 171 | + | // Rules N1-N3. | |
| 172 | + | seq.resolveNeutralTypes() | |
| 173 | + | ||
| 174 | + | // 5) resolving implicit embedding levels | |
| 175 | + | // Rules I1, I2. | |
| 176 | + | seq.resolveImplicitLevels() | |
| 177 | + | ||
| 178 | + | // Apply the computed levels and types | |
| 179 | + | seq.applyLevelsAndTypes() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Assign appropriate levels to 'hide' LREs, RLEs, LROs, RLOs, PDFs, and | |
| 183 | + | // BNs. This is for convenience, so the resulting level array will have | |
| 184 | + | // a value for every character. | |
| 185 | + | p.assignLevelsToCharactersRemovedByX9() | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // determineMatchingIsolates determines the matching PDI for each isolate | |
| 189 | + | // initiator and vice versa. | |
| 190 | + | // | |
| 191 | + | // Definition BD9. | |
| 192 | + | // | |
| 193 | + | // At the end of this function: | |
| 194 | + | // | |
| 195 | + | // - The member variable matchingPDI is set to point to the index of the | |
| 196 | + | // matching PDI character for each isolate initiator character. If there is | |
| 197 | + | // no matching PDI, it is set to the length of the input text. For other | |
| 198 | + | // characters, it is set to -1. | |
| 199 | + | // - The member variable matchingIsolateInitiator is set to point to the | |
| 200 | + | // index of the matching isolate initiator character for each PDI character. | |
| 201 | + | // If there is no matching isolate initiator, or the character is not a PDI, | |
| 202 | + | // it is set to -1. | |
| 203 | + | func (p *paragraph) determineMatchingIsolates() { | |
| 204 | + | p.matchingPDI = make([]int, p.Len()) | |
| 205 | + | p.matchingIsolateInitiator = make([]int, p.Len()) | |
| 206 | + | ||
| 207 | + | for i := range p.matchingIsolateInitiator { | |
| 208 | + | p.matchingIsolateInitiator[i] = -1 | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | for i := range p.matchingPDI { | |
| 212 | + | p.matchingPDI[i] = -1 | |
| 213 | + | ||
| 214 | + | if t := p.resultTypes[i]; t.in(LRI, RLI, FSI) { | |
| 215 | + | depthCounter := 1 | |
| 216 | + | for j := i + 1; j < p.Len(); j++ { | |
| 217 | + | if u := p.resultTypes[j]; u.in(LRI, RLI, FSI) { | |
| 218 | + | depthCounter++ | |
| 219 | + | } else if u == PDI { | |
| 220 | + | if depthCounter--; depthCounter == 0 { | |
| 221 | + | p.matchingPDI[i] = j | |
| 222 | + | p.matchingIsolateInitiator[j] = i | |
| 223 | + | break | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | if p.matchingPDI[i] == -1 { | |
| 228 | + | p.matchingPDI[i] = p.Len() | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // determineParagraphEmbeddingLevel reports the resolved paragraph direction of | |
| 235 | + | // the substring limited by the given range [start, end). | |
| 236 | + | // | |
| 237 | + | // Determines the paragraph level based on rules P2, P3. This is also used | |
| 238 | + | // in rule X5c to find if an FSI should resolve to LRI or RLI. | |
| 239 | + | func (p *paragraph) determineParagraphEmbeddingLevel(start, end int) level { | |
| 240 | + | var strongType Class = unknownClass | |
| 241 | + | ||
| 242 | + | // Rule P2. | |
| 243 | + | for i := start; i < end; i++ { | |
| 244 | + | if t := p.resultTypes[i]; t.in(L, AL, R) { | |
| 245 | + | strongType = t | |
| 246 | + | break | |
| 247 | + | } else if t.in(FSI, LRI, RLI) { | |
| 248 | + | i = p.matchingPDI[i] // skip over to the matching PDI | |
| 249 | + | if i > end { | |
| 250 | + | panic("assert (i <= end)") | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | // Rule P3. | |
| 255 | + | switch strongType { | |
| 256 | + | case unknownClass: // none found | |
| 257 | + | // default embedding level when no strong types found is 0. | |
| 258 | + | return 0 | |
| 259 | + | case L: | |
| 260 | + | return 0 | |
| 261 | + | default: // AL, R | |
| 262 | + | return 1 | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | const maxDepth = 125 | |
| 267 | + | ||
| 268 | + | // This stack will store the embedding levels and override and isolated | |
| 269 | + | // statuses | |
| 270 | + | type directionalStatusStack struct { | |
| 271 | + | stackCounter int | |
| 272 | + | embeddingLevelStack [maxDepth + 1]level | |
| 273 | + | overrideStatusStack [maxDepth + 1]Class | |
| 274 | + | isolateStatusStack [maxDepth + 1]bool | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (s *directionalStatusStack) empty() { s.stackCounter = 0 } | |
| 278 | + | func (s *directionalStatusStack) pop() { s.stackCounter-- } | |
| 279 | + | func (s *directionalStatusStack) depth() int { return s.stackCounter } | |
| 280 | + | ||
| 281 | + | func (s *directionalStatusStack) push(level level, overrideStatus Class, isolateStatus bool) { | |
| 282 | + | s.embeddingLevelStack[s.stackCounter] = level | |
| 283 | + | s.overrideStatusStack[s.stackCounter] = overrideStatus | |
| 284 | + | s.isolateStatusStack[s.stackCounter] = isolateStatus | |
| 285 | + | s.stackCounter++ | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (s *directionalStatusStack) lastEmbeddingLevel() level { | |
| 289 | + | return s.embeddingLevelStack[s.stackCounter-1] | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (s *directionalStatusStack) lastDirectionalOverrideStatus() Class { | |
| 293 | + | return s.overrideStatusStack[s.stackCounter-1] | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func (s *directionalStatusStack) lastDirectionalIsolateStatus() bool { | |
| 297 | + | return s.isolateStatusStack[s.stackCounter-1] | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // Determine explicit levels using rules X1 - X8 | |
| 301 | + | func (p *paragraph) determineExplicitEmbeddingLevels() { | |
| 302 | + | var stack directionalStatusStack | |
| 303 | + | var overflowIsolateCount, overflowEmbeddingCount, validIsolateCount int | |
| 304 | + | ||
| 305 | + | // Rule X1. | |
| 306 | + | stack.push(p.embeddingLevel, ON, false) | |
| 307 | + | ||
| 308 | + | for i, t := range p.resultTypes { | |
| 309 | + | // Rules X2, X3, X4, X5, X5a, X5b, X5c | |
| 310 | + | switch t { | |
| 311 | + | case RLE, LRE, RLO, LRO, RLI, LRI, FSI: | |
| 312 | + | isIsolate := t.in(RLI, LRI, FSI) | |
| 313 | + | isRTL := t.in(RLE, RLO, RLI) | |
| 314 | + | ||
| 315 | + | // override if this is an FSI that resolves to RLI | |
| 316 | + | if t == FSI { | |
| 317 | + | isRTL = (p.determineParagraphEmbeddingLevel(i+1, p.matchingPDI[i]) == 1) | |
| 318 | + | } | |
| 319 | + | if isIsolate { | |
| 320 | + | p.resultLevels[i] = stack.lastEmbeddingLevel() | |
| 321 | + | if stack.lastDirectionalOverrideStatus() != ON { | |
| 322 | + | p.resultTypes[i] = stack.lastDirectionalOverrideStatus() | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | var newLevel level | |
| 327 | + | if isRTL { | |
| 328 | + | // least greater odd | |
| 329 | + | newLevel = (stack.lastEmbeddingLevel() + 1) | 1 | |
| 330 | + | } else { | |
| 331 | + | // least greater even | |
| 332 | + | newLevel = (stack.lastEmbeddingLevel() + 2) &^ 1 | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | if newLevel <= maxDepth && overflowIsolateCount == 0 && overflowEmbeddingCount == 0 { | |
| 336 | + | if isIsolate { | |
| 337 | + | validIsolateCount++ | |
| 338 | + | } | |
| 339 | + | // Push new embedding level, override status, and isolated | |
| 340 | + | // status. | |
| 341 | + | // No check for valid stack counter, since the level check | |
| 342 | + | // suffices. | |
| 343 | + | switch t { | |
| 344 | + | case LRO: | |
| 345 | + | stack.push(newLevel, L, isIsolate) | |
| 346 | + | case RLO: | |
| 347 | + | stack.push(newLevel, R, isIsolate) | |
| 348 | + | default: | |
| 349 | + | stack.push(newLevel, ON, isIsolate) | |
| 350 | + | } | |
| 351 | + | // Not really part of the spec | |
| 352 | + | if !isIsolate { | |
| 353 | + | p.resultLevels[i] = newLevel | |
| 354 | + | } | |
| 355 | + | } else { | |
| 356 | + | // This is an invalid explicit formatting character, | |
| 357 | + | // so apply the "Otherwise" part of rules X2-X5b. | |
| 358 | + | if isIsolate { | |
| 359 | + | overflowIsolateCount++ | |
| 360 | + | } else { // !isIsolate | |
| 361 | + | if overflowIsolateCount == 0 { | |
| 362 | + | overflowEmbeddingCount++ | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // Rule X6a | |
| 368 | + | case PDI: | |
| 369 | + | if overflowIsolateCount > 0 { | |
| 370 | + | overflowIsolateCount-- | |
| 371 | + | } else if validIsolateCount == 0 { | |
| 372 | + | // do nothing | |
| 373 | + | } else { | |
| 374 | + | overflowEmbeddingCount = 0 | |
| 375 | + | for !stack.lastDirectionalIsolateStatus() { | |
| 376 | + | stack.pop() | |
| 377 | + | } | |
| 378 | + | stack.pop() | |
| 379 | + | validIsolateCount-- | |
| 380 | + | } | |
| 381 | + | p.resultLevels[i] = stack.lastEmbeddingLevel() | |
| 382 | + | ||
| 383 | + | // Rule X7 | |
| 384 | + | case PDF: | |
| 385 | + | // Not really part of the spec | |
| 386 | + | p.resultLevels[i] = stack.lastEmbeddingLevel() | |
| 387 | + | ||
| 388 | + | if overflowIsolateCount > 0 { | |
| 389 | + | // do nothing | |
| 390 | + | } else if overflowEmbeddingCount > 0 { | |
| 391 | + | overflowEmbeddingCount-- | |
| 392 | + | } else if !stack.lastDirectionalIsolateStatus() && stack.depth() >= 2 { | |
| 393 | + | stack.pop() | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | case B: // paragraph separator. | |
| 397 | + | // Rule X8. | |
| 398 | + | ||
| 399 | + | // These values are reset for clarity, in this implementation B | |
| 400 | + | // can only occur as the last code in the array. | |
| 401 | + | stack.empty() | |
| 402 | + | overflowIsolateCount = 0 | |
| 403 | + | overflowEmbeddingCount = 0 | |
| 404 | + | validIsolateCount = 0 | |
| 405 | + | p.resultLevels[i] = p.embeddingLevel | |
| 406 | + | ||
| 407 | + | default: | |
| 408 | + | p.resultLevels[i] = stack.lastEmbeddingLevel() | |
| 409 | + | if stack.lastDirectionalOverrideStatus() != ON { | |
| 410 | + | p.resultTypes[i] = stack.lastDirectionalOverrideStatus() | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | type isolatingRunSequence struct { | |
| 417 | + | p *paragraph | |
| 418 | + | ||
| 419 | + | indexes []int // indexes to the original string | |
| 420 | + | ||
| 421 | + | types []Class // type of each character using the index | |
| 422 | + | resolvedLevels []level // resolved levels after application of rules | |
| 423 | + | level level | |
| 424 | + | sos, eos Class | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (i *isolatingRunSequence) Len() int { return len(i.indexes) } | |
| 428 | + | ||
| 429 | + | // Rule X10, second bullet: Determine the start-of-sequence (sos) and end-of-sequence (eos) types, | |
| 430 | + | // either L or R, for each isolating run sequence. | |
| 431 | + | func (p *paragraph) isolatingRunSequence(indexes []int) *isolatingRunSequence { | |
| 432 | + | length := len(indexes) | |
| 433 | + | types := make([]Class, length) | |
| 434 | + | for i, x := range indexes { | |
| 435 | + | types[i] = p.resultTypes[x] | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // assign level, sos and eos | |
| 439 | + | prevChar := indexes[0] - 1 | |
| 440 | + | for prevChar >= 0 && isRemovedByX9(p.initialTypes[prevChar]) { | |
| 441 | + | prevChar-- | |
| 442 | + | } | |
| 443 | + | prevLevel := p.embeddingLevel | |
| 444 | + | if prevChar >= 0 { | |
| 445 | + | prevLevel = p.resultLevels[prevChar] | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | var succLevel level | |
| 449 | + | lastType := types[length-1] | |
| 450 | + | if lastType.in(LRI, RLI, FSI) { | |
| 451 | + | succLevel = p.embeddingLevel | |
| 452 | + | } else { | |
| 453 | + | // the first character after the end of run sequence | |
| 454 | + | limit := indexes[length-1] + 1 | |
| 455 | + | for ; limit < p.Len() && isRemovedByX9(p.initialTypes[limit]); limit++ { | |
| 456 | + | ||
| 457 | + | } | |
| 458 | + | succLevel = p.embeddingLevel | |
| 459 | + | if limit < p.Len() { | |
| 460 | + | succLevel = p.resultLevels[limit] | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | level := p.resultLevels[indexes[0]] | |
| 464 | + | return &isolatingRunSequence{ | |
| 465 | + | p: p, | |
| 466 | + | indexes: indexes, | |
| 467 | + | types: types, | |
| 468 | + | level: level, | |
| 469 | + | sos: typeForLevel(max(prevLevel, level)), | |
| 470 | + | eos: typeForLevel(max(succLevel, level)), | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // Resolving weak types Rules W1-W7. | |
| 475 | + | // | |
| 476 | + | // Note that some weak types (EN, AN) remain after this processing is | |
| 477 | + | // complete. | |
| 478 | + | func (s *isolatingRunSequence) resolveWeakTypes() { | |
| 479 | + | ||
| 480 | + | // on entry, only these types remain | |
| 481 | + | s.assertOnly(L, R, AL, EN, ES, ET, AN, CS, B, S, WS, ON, NSM, LRI, RLI, FSI, PDI) | |
| 482 | + | ||
| 483 | + | // Rule W1. | |
| 484 | + | // Changes all NSMs. | |
| 485 | + | precedingCharacterType := s.sos | |
| 486 | + | for i, t := range s.types { | |
| 487 | + | if t == NSM { | |
| 488 | + | s.types[i] = precedingCharacterType | |
| 489 | + | } else { | |
| 490 | + | // if t.in(LRI, RLI, FSI, PDI) { | |
| 491 | + | // precedingCharacterType = ON | |
| 492 | + | // } | |
| 493 | + | precedingCharacterType = t | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // Rule W2. | |
| 498 | + | // EN does not change at the start of the run, because sos != AL. | |
| 499 | + | for i, t := range s.types { | |
| 500 | + | if t == EN { | |
| 501 | + | for j := i - 1; j >= 0; j-- { | |
| 502 | + | if t := s.types[j]; t.in(L, R, AL) { | |
| 503 | + | if t == AL { | |
| 504 | + | s.types[i] = AN | |
| 505 | + | } | |
| 506 | + | break | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // Rule W3. | |
| 513 | + | for i, t := range s.types { | |
| 514 | + | if t == AL { | |
| 515 | + | s.types[i] = R | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // Rule W4. | |
| 520 | + | // Since there must be values on both sides for this rule to have an | |
| 521 | + | // effect, the scan skips the first and last value. | |
| 522 | + | // | |
| 523 | + | // Although the scan proceeds left to right, and changes the type | |
| 524 | + | // values in a way that would appear to affect the computations | |
| 525 | + | // later in the scan, there is actually no problem. A change in the | |
| 526 | + | // current value can only affect the value to its immediate right, | |
| 527 | + | // and only affect it if it is ES or CS. But the current value can | |
| 528 | + | // only change if the value to its right is not ES or CS. Thus | |
| 529 | + | // either the current value will not change, or its change will have | |
| 530 | + | // no effect on the remainder of the analysis. | |
| 531 | + | ||
| 532 | + | for i := 1; i < s.Len()-1; i++ { | |
| 533 | + | t := s.types[i] | |
| 534 | + | if t == ES || t == CS { | |
| 535 | + | prevSepType := s.types[i-1] | |
| 536 | + | succSepType := s.types[i+1] | |
| 537 | + | if prevSepType == EN && succSepType == EN { | |
| 538 | + | s.types[i] = EN | |
| 539 | + | } else if s.types[i] == CS && prevSepType == AN && succSepType == AN { | |
| 540 | + | s.types[i] = AN | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // Rule W5. | |
| 546 | + | for i, t := range s.types { | |
| 547 | + | if t == ET { | |
| 548 | + | // locate end of sequence | |
| 549 | + | runStart := i | |
| 550 | + | runEnd := s.findRunLimit(runStart, ET) | |
| 551 | + | ||
| 552 | + | // check values at ends of sequence | |
| 553 | + | t := s.sos | |
| 554 | + | if runStart > 0 { | |
| 555 | + | t = s.types[runStart-1] | |
| 556 | + | } | |
| 557 | + | if t != EN { | |
| 558 | + | t = s.eos | |
| 559 | + | if runEnd < len(s.types) { | |
| 560 | + | t = s.types[runEnd] | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | if t == EN { | |
| 564 | + | setTypes(s.types[runStart:runEnd], EN) | |
| 565 | + | } | |
| 566 | + | // continue at end of sequence | |
| 567 | + | i = runEnd | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // Rule W6. | |
| 572 | + | for i, t := range s.types { | |
| 573 | + | if t.in(ES, ET, CS) { | |
| 574 | + | s.types[i] = ON | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // Rule W7. | |
| 579 | + | for i, t := range s.types { | |
| 580 | + | if t == EN { | |
| 581 | + | // set default if we reach start of run | |
| 582 | + | prevStrongType := s.sos | |
| 583 | + | for j := i - 1; j >= 0; j-- { | |
| 584 | + | t = s.types[j] | |
| 585 | + | if t == L || t == R { // AL's have been changed to R | |
| 586 | + | prevStrongType = t | |
| 587 | + | break | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | if prevStrongType == L { | |
| 591 | + | s.types[i] = L | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // 6) resolving neutral types Rules N1-N2. | |
| 598 | + | func (s *isolatingRunSequence) resolveNeutralTypes() { | |
| 599 | + | ||
| 600 | + | // on entry, only these types can be in resultTypes | |
| 601 | + | s.assertOnly(L, R, EN, AN, B, S, WS, ON, RLI, LRI, FSI, PDI) | |
| 602 | + | ||
| 603 | + | for i, t := range s.types { | |
| 604 | + | switch t { | |
| 605 | + | case WS, ON, B, S, RLI, LRI, FSI, PDI: | |
| 606 | + | // find bounds of run of neutrals | |
| 607 | + | runStart := i | |
| 608 | + | runEnd := s.findRunLimit(runStart, B, S, WS, ON, RLI, LRI, FSI, PDI) | |
| 609 | + | ||
| 610 | + | // determine effective types at ends of run | |
| 611 | + | var leadType, trailType Class | |
| 612 | + | ||
| 613 | + | // Note that the character found can only be L, R, AN, or | |
| 614 | + | // EN. | |
| 615 | + | if runStart == 0 { | |
| 616 | + | leadType = s.sos | |
| 617 | + | } else { | |
| 618 | + | leadType = s.types[runStart-1] | |
| 619 | + | if leadType.in(AN, EN) { | |
| 620 | + | leadType = R | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | if runEnd == len(s.types) { | |
| 624 | + | trailType = s.eos | |
| 625 | + | } else { | |
| 626 | + | trailType = s.types[runEnd] | |
| 627 | + | if trailType.in(AN, EN) { | |
| 628 | + | trailType = R | |
| 629 | + | } | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | var resolvedType Class | |
| 633 | + | if leadType == trailType { | |
| 634 | + | // Rule N1. | |
| 635 | + | resolvedType = leadType | |
| 636 | + | } else { | |
| 637 | + | // Rule N2. | |
| 638 | + | // Notice the embedding level of the run is used, not | |
| 639 | + | // the paragraph embedding level. | |
| 640 | + | resolvedType = typeForLevel(s.level) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | setTypes(s.types[runStart:runEnd], resolvedType) | |
| 644 | + | ||
| 645 | + | // skip over run of (former) neutrals | |
| 646 | + | i = runEnd | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | func setLevels(levels []level, newLevel level) { | |
| 652 | + | for i := range levels { | |
| 653 | + | levels[i] = newLevel | |
| 654 | + | } | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | func setTypes(types []Class, newType Class) { | |
| 658 | + | for i := range types { | |
| 659 | + | types[i] = newType | |
| 660 | + | } | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | // 7) resolving implicit embedding levels Rules I1, I2. | |
| 664 | + | func (s *isolatingRunSequence) resolveImplicitLevels() { | |
| 665 | + | ||
| 666 | + | // on entry, only these types can be in resultTypes | |
| 667 | + | s.assertOnly(L, R, EN, AN) | |
| 668 | + | ||
| 669 | + | s.resolvedLevels = make([]level, len(s.types)) | |
| 670 | + | setLevels(s.resolvedLevels, s.level) | |
| 671 | + | ||
| 672 | + | if (s.level & 1) == 0 { // even level | |
| 673 | + | for i, t := range s.types { | |
| 674 | + | // Rule I1. | |
| 675 | + | if t == L { | |
| 676 | + | // no change | |
| 677 | + | } else if t == R { | |
| 678 | + | s.resolvedLevels[i] += 1 | |
| 679 | + | } else { // t == AN || t == EN | |
| 680 | + | s.resolvedLevels[i] += 2 | |
| 681 | + | } | |
| 682 | + | } | |
| 683 | + | } else { // odd level | |
| 684 | + | for i, t := range s.types { | |
| 685 | + | // Rule I2. | |
| 686 | + | if t == R { | |
| 687 | + | // no change | |
| 688 | + | } else { // t == L || t == AN || t == EN | |
| 689 | + | s.resolvedLevels[i] += 1 | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | // Applies the levels and types resolved in rules W1-I2 to the | |
| 696 | + | // resultLevels array. | |
| 697 | + | func (s *isolatingRunSequence) applyLevelsAndTypes() { | |
| 698 | + | for i, x := range s.indexes { | |
| 699 | + | s.p.resultTypes[x] = s.types[i] | |
| 700 | + | s.p.resultLevels[x] = s.resolvedLevels[i] | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | // Return the limit of the run consisting only of the types in validSet | |
| 705 | + | // starting at index. This checks the value at index, and will return | |
| 706 | + | // index if that value is not in validSet. | |
| 707 | + | func (s *isolatingRunSequence) findRunLimit(index int, validSet ...Class) int { | |
| 708 | + | loop: | |
| 709 | + | for ; index < len(s.types); index++ { | |
| 710 | + | t := s.types[index] | |
| 711 | + | for _, valid := range validSet { | |
| 712 | + | if t == valid { | |
| 713 | + | continue loop | |
| 714 | + | } | |
| 715 | + | } | |
| 716 | + | return index // didn't find a match in validSet | |
| 717 | + | } | |
| 718 | + | return len(s.types) | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | // Algorithm validation. Assert that all values in types are in the | |
| 722 | + | // provided set. | |
| 723 | + | func (s *isolatingRunSequence) assertOnly(codes ...Class) { | |
| 724 | + | loop: | |
| 725 | + | for i, t := range s.types { | |
| 726 | + | for _, c := range codes { | |
| 727 | + | if t == c { | |
| 728 | + | continue loop | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | panic(fmt.Errorf("invalid bidi code %v present in assertOnly at position %d", t, s.indexes[i])) | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | // determineLevelRuns returns an array of level runs. Each level run is | |
| 736 | + | // described as an array of indexes into the input string. | |
| 737 | + | // | |
| 738 | + | // Determines the level runs. Rule X9 will be applied in determining the | |
| 739 | + | // runs, in the way that makes sure the characters that are supposed to be | |
| 740 | + | // removed are not included in the runs. | |
| 741 | + | func (p *paragraph) determineLevelRuns() [][]int { | |
| 742 | + | run := []int{} | |
| 743 | + | allRuns := [][]int{} | |
| 744 | + | currentLevel := implicitLevel | |
| 745 | + | ||
| 746 | + | for i := range p.initialTypes { | |
| 747 | + | if !isRemovedByX9(p.initialTypes[i]) { | |
| 748 | + | if p.resultLevels[i] != currentLevel { | |
| 749 | + | // we just encountered a new run; wrap up last run | |
| 750 | + | if currentLevel >= 0 { // only wrap it up if there was a run | |
| 751 | + | allRuns = append(allRuns, run) | |
| 752 | + | run = nil | |
| 753 | + | } | |
| 754 | + | // Start new run | |
| 755 | + | currentLevel = p.resultLevels[i] | |
| 756 | + | } | |
| 757 | + | run = append(run, i) | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | // Wrap up the final run, if any | |
| 761 | + | if len(run) > 0 { | |
| 762 | + | allRuns = append(allRuns, run) | |
| 763 | + | } | |
| 764 | + | return allRuns | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | // Definition BD13. Determine isolating run sequences. | |
| 768 | + | func (p *paragraph) determineIsolatingRunSequences() []*isolatingRunSequence { | |
| 769 | + | levelRuns := p.determineLevelRuns() | |
| 770 | + | ||
| 771 | + | // Compute the run that each character belongs to | |
| 772 | + | runForCharacter := make([]int, p.Len()) | |
| 773 | + | for i, run := range levelRuns { | |
| 774 | + | for _, index := range run { | |
| 775 | + | runForCharacter[index] = i | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | sequences := []*isolatingRunSequence{} | |
| 780 | + | ||
| 781 | + | var currentRunSequence []int | |
| 782 | + | ||
| 783 | + | for _, run := range levelRuns { | |
| 784 | + | first := run[0] | |
| 785 | + | if p.initialTypes[first] != PDI || p.matchingIsolateInitiator[first] == -1 { | |
| 786 | + | currentRunSequence = nil | |
| 787 | + | // int run = i; | |
| 788 | + | for { | |
| 789 | + | // Copy this level run into currentRunSequence | |
| 790 | + | currentRunSequence = append(currentRunSequence, run...) | |
| 791 | + | ||
| 792 | + | last := currentRunSequence[len(currentRunSequence)-1] | |
| 793 | + | lastT := p.initialTypes[last] | |
| 794 | + | if lastT.in(LRI, RLI, FSI) && p.matchingPDI[last] != p.Len() { | |
| 795 | + | run = levelRuns[runForCharacter[p.matchingPDI[last]]] | |
| 796 | + | } else { | |
| 797 | + | break | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | sequences = append(sequences, p.isolatingRunSequence(currentRunSequence)) | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | return sequences | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // Assign level information to characters removed by rule X9. This is for | |
| 807 | + | // ease of relating the level information to the original input data. Note | |
| 808 | + | // that the levels assigned to these codes are arbitrary, they're chosen so | |
| 809 | + | // as to avoid breaking level runs. | |
| 810 | + | func (p *paragraph) assignLevelsToCharactersRemovedByX9() { | |
| 811 | + | for i, t := range p.initialTypes { | |
| 812 | + | if t.in(LRE, RLE, LRO, RLO, PDF, BN) { | |
| 813 | + | p.resultTypes[i] = t | |
| 814 | + | p.resultLevels[i] = -1 | |
| 815 | + | } | |
| 816 | + | } | |
| 817 | + | // now propagate forward the levels information (could have | |
| 818 | + | // propagated backward, the main thing is not to introduce a level | |
| 819 | + | // break where one doesn't already exist). | |
| 820 | + | ||
| 821 | + | if p.resultLevels[0] == -1 { | |
| 822 | + | p.resultLevels[0] = p.embeddingLevel | |
| 823 | + | } | |
| 824 | + | for i := 1; i < len(p.initialTypes); i++ { | |
| 825 | + | if p.resultLevels[i] == -1 { | |
| 826 | + | p.resultLevels[i] = p.resultLevels[i-1] | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | // Embedding information is for informational purposes only so need not be | |
| 830 | + | // adjusted. | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | // | |
| 834 | + | // Output | |
| 835 | + | // | |
| 836 | + | ||
| 837 | + | // getLevels computes levels array breaking lines at offsets in linebreaks. | |
| 838 | + | // Rule L1. | |
| 839 | + | // | |
| 840 | + | // The linebreaks array must include at least one value. The values must be | |
| 841 | + | // in strictly increasing order (no duplicates) between 1 and the length of | |
| 842 | + | // the text, inclusive. The last value must be the length of the text. | |
| 843 | + | func (p *paragraph) getLevels(linebreaks []int) []level { | |
| 844 | + | // Note that since the previous processing has removed all | |
| 845 | + | // P, S, and WS values from resultTypes, the values referred to | |
| 846 | + | // in these rules are the initial types, before any processing | |
| 847 | + | // has been applied (including processing of overrides). | |
| 848 | + | // | |
| 849 | + | // This example implementation has reinserted explicit format codes | |
| 850 | + | // and BN, in order that the levels array correspond to the | |
| 851 | + | // initial text. Their final placement is not normative. | |
| 852 | + | // These codes are treated like WS in this implementation, | |
| 853 | + | // so they don't interrupt sequences of WS. | |
| 854 | + | ||
| 855 | + | validateLineBreaks(linebreaks, p.Len()) | |
| 856 | + | ||
| 857 | + | result := append([]level(nil), p.resultLevels...) | |
| 858 | + | ||
| 859 | + | // don't worry about linebreaks since if there is a break within | |
| 860 | + | // a series of WS values preceding S, the linebreak itself | |
| 861 | + | // causes the reset. | |
| 862 | + | for i, t := range p.initialTypes { | |
| 863 | + | if t.in(B, S) { | |
| 864 | + | // Rule L1, clauses one and two. | |
| 865 | + | result[i] = p.embeddingLevel | |
| 866 | + | ||
| 867 | + | // Rule L1, clause three. | |
| 868 | + | for j := i - 1; j >= 0; j-- { | |
| 869 | + | if isWhitespace(p.initialTypes[j]) { // including format codes | |
| 870 | + | result[j] = p.embeddingLevel | |
| 871 | + | } else { | |
| 872 | + | break | |
| 873 | + | } | |
| 874 | + | } | |
| 875 | + | } | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | // Rule L1, clause four. | |
| 879 | + | start := 0 | |
| 880 | + | for _, limit := range linebreaks { | |
| 881 | + | for j := limit - 1; j >= start; j-- { | |
| 882 | + | if isWhitespace(p.initialTypes[j]) { // including format codes | |
| 883 | + | result[j] = p.embeddingLevel | |
| 884 | + | } else { | |
| 885 | + | break | |
| 886 | + | } | |
| 887 | + | } | |
| 888 | + | start = limit | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | return result | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | // getReordering returns the reordering of lines from a visual index to a | |
| 895 | + | // logical index for line breaks at the given offsets. | |
| 896 | + | // | |
| 897 | + | // Lines are concatenated from left to right. So for example, the fifth | |
| 898 | + | // character from the left on the third line is | |
| 899 | + | // | |
| 900 | + | // getReordering(linebreaks)[linebreaks[1] + 4] | |
| 901 | + | // | |
| 902 | + | // (linebreaks[1] is the position after the last character of the second | |
| 903 | + | // line, which is also the index of the first character on the third line, | |
| 904 | + | // and adding four gets the fifth character from the left). | |
| 905 | + | // | |
| 906 | + | // The linebreaks array must include at least one value. The values must be | |
| 907 | + | // in strictly increasing order (no duplicates) between 1 and the length of | |
| 908 | + | // the text, inclusive. The last value must be the length of the text. | |
| 909 | + | // nefergui: retain upstream L2 helper for differential conformance checks. | |
| 910 | + | func (p *paragraph) getReordering(linebreaks []int) []int { | |
| 911 | + | validateLineBreaks(linebreaks, p.Len()) | |
| 912 | + | ||
| 913 | + | return computeMultilineReordering(p.getLevels(linebreaks), linebreaks) | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | // Return multiline reordering array for a given level array. Reordering | |
| 917 | + | // does not occur across a line break. | |
| 918 | + | func computeMultilineReordering(levels []level, linebreaks []int) []int { | |
| 919 | + | result := make([]int, len(levels)) | |
| 920 | + | ||
| 921 | + | start := 0 | |
| 922 | + | for _, limit := range linebreaks { | |
| 923 | + | tempLevels := make([]level, limit-start) | |
| 924 | + | copy(tempLevels, levels[start:]) | |
| 925 | + | ||
| 926 | + | for j, order := range computeReordering(tempLevels) { | |
| 927 | + | result[start+j] = order + start | |
| 928 | + | } | |
| 929 | + | start = limit | |
| 930 | + | } | |
| 931 | + | return result | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | // Return reordering array for a given level array. This reorders a single | |
| 935 | + | // line. The reordering is a visual to logical map. For example, the | |
| 936 | + | // leftmost char is string.charAt(order[0]). Rule L2. | |
| 937 | + | func computeReordering(levels []level) []int { | |
| 938 | + | result := make([]int, len(levels)) | |
| 939 | + | // initialize order | |
| 940 | + | for i := range result { | |
| 941 | + | result[i] = i | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | // locate highest level found on line. | |
| 945 | + | // Note the rules say text, but no reordering across line bounds is | |
| 946 | + | // performed, so this is sufficient. | |
| 947 | + | highestLevel := level(0) | |
| 948 | + | lowestOddLevel := level(maxDepth + 2) | |
| 949 | + | for _, level := range levels { | |
| 950 | + | if level > highestLevel { | |
| 951 | + | highestLevel = level | |
| 952 | + | } | |
| 953 | + | if level&1 != 0 && level < lowestOddLevel { | |
| 954 | + | lowestOddLevel = level | |
| 955 | + | } | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | for level := highestLevel; level >= lowestOddLevel; level-- { | |
| 959 | + | for i := 0; i < len(levels); i++ { | |
| 960 | + | if levels[i] >= level { | |
| 961 | + | // find range of text at or above this level | |
| 962 | + | start := i | |
| 963 | + | limit := i + 1 | |
| 964 | + | for limit < len(levels) && levels[limit] >= level { | |
| 965 | + | limit++ | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | for j, k := start, limit-1; j < k; j, k = j+1, k-1 { | |
| 969 | + | result[j], result[k] = result[k], result[j] | |
| 970 | + | } | |
| 971 | + | // skip to end of level run | |
| 972 | + | i = limit | |
| 973 | + | } | |
| 974 | + | } | |
| 975 | + | } | |
| 976 | + | ||
| 977 | + | return result | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | // isWhitespace reports whether the type is considered a whitespace type for the | |
| 981 | + | // line break rules. | |
| 982 | + | func isWhitespace(c Class) bool { | |
| 983 | + | switch c { | |
| 984 | + | case LRE, RLE, LRO, RLO, PDF, LRI, RLI, FSI, PDI, BN, WS: | |
| 985 | + | return true | |
| 986 | + | } | |
| 987 | + | return false | |
| 988 | + | } | |
| 989 | + | ||
| 990 | + | // isRemovedByX9 reports whether the type is one of the types removed in X9. | |
| 991 | + | func isRemovedByX9(c Class) bool { | |
| 992 | + | switch c { | |
| 993 | + | case LRE, RLE, LRO, RLO, PDF, BN: | |
| 994 | + | return true | |
| 995 | + | } | |
| 996 | + | return false | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | // typeForLevel reports the strong type (L or R) corresponding to the level. | |
| 1000 | + | func typeForLevel(level level) Class { | |
| 1001 | + | if (level & 0x1) == 0 { | |
| 1002 | + | return L | |
| 1003 | + | } | |
| 1004 | + | return R | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | func validateTypes(types []Class) error { | |
| 1008 | + | if len(types) == 0 { | |
| 1009 | + | return fmt.Errorf("types is null") | |
| 1010 | + | } | |
| 1011 | + | for i, t := range types[:len(types)-1] { | |
| 1012 | + | if t == B { | |
| 1013 | + | // nefergui: staticcheck ST1005 requires lowercase error strings. | |
| 1014 | + | return fmt.Errorf("b type before end of paragraph at index: %d", i) | |
| 1015 | + | } | |
| 1016 | + | } | |
| 1017 | + | return nil | |
| 1018 | + | } | |
| 1019 | + | ||
| 1020 | + | func validateParagraphEmbeddingLevel(embeddingLevel level) error { | |
| 1021 | + | if embeddingLevel != implicitLevel && | |
| 1022 | + | embeddingLevel != 0 && | |
| 1023 | + | embeddingLevel != 1 { | |
| 1024 | + | return fmt.Errorf("illegal paragraph embedding level: %d", embeddingLevel) | |
| 1025 | + | } | |
| 1026 | + | return nil | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | func validateLineBreaks(linebreaks []int, textLength int) error { | |
| 1030 | + | prev := 0 | |
| 1031 | + | for i, next := range linebreaks { | |
| 1032 | + | if next <= prev { | |
| 1033 | + | return fmt.Errorf("bad linebreak: %d at index: %d", next, i) | |
| 1034 | + | } | |
| 1035 | + | prev = next | |
| 1036 | + | } | |
| 1037 | + | if prev != textLength { | |
| 1038 | + | return fmt.Errorf("last linebreak was %d, want %d", prev, textLength) | |
| 1039 | + | } | |
| 1040 | + | return nil | |
| 1041 | + | } | |
| 1042 | + | ||
| 1043 | + | func validatePbTypes(pairTypes []bracketType) error { | |
| 1044 | + | if len(pairTypes) == 0 { | |
| 1045 | + | return fmt.Errorf("pairTypes is null") | |
| 1046 | + | } | |
| 1047 | + | for i, pt := range pairTypes { | |
| 1048 | + | switch pt { | |
| 1049 | + | case bpNone, bpOpen, bpClose: | |
| 1050 | + | default: | |
| 1051 | + | return fmt.Errorf("illegal pairType value at %d: %v", i, pairTypes[i]) | |
| 1052 | + | } | |
| 1053 | + | } | |
| 1054 | + | return nil | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | func validatePbValues(pairValues []rune, pairTypes []bracketType) error { | |
| 1058 | + | if pairValues == nil { | |
| 1059 | + | return fmt.Errorf("pairValues is null") | |
| 1060 | + | } | |
| 1061 | + | if len(pairTypes) != len(pairValues) { | |
| 1062 | + | return fmt.Errorf("pairTypes is different length from pairValues") | |
| 1063 | + | } | |
| 1064 | + | return nil | |
| 1065 | + | } | |
A internal/bidi/levels.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package bidi | |
| 2 | + | ||
| 3 | + | import "fmt" | |
| 4 | + | ||
| 5 | + | // BaseDirection selects the paragraph embedding level. Auto follows P2/P3. | |
| 6 | + | // nefergui: expose the existing upstream paragraph algorithm's resolved levels. | |
| 7 | + | type BaseDirection uint8 | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | Auto BaseDirection = iota | |
| 11 | + | LTR | |
| 12 | + | RTL | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Levels returns resolved embedding levels after L1, one per input rune. | |
| 16 | + | // lineBreaks contains strictly increasing, nonempty, rune offsets, ending in len(text). | |
| 17 | + | // Removed X9 formatting codes receive a convenience level; call Reorder with | |
| 18 | + | // the same text to omit those codes from the visual indices. | |
| 19 | + | // nefergui: adapt upstream newParagraph/getLevels for per-line layout consumers. | |
| 20 | + | func Levels(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, error) { | |
| 21 | + | levels, _, err := Resolve(text, base, lineBreaks) | |
| 22 | + | return levels, err | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Resolve returns the paragraph base level and the resolved levels after L1. | |
| 26 | + | // nefergui: surface existing levels without copying or reclassifying UAX#9 classes. | |
| 27 | + | func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) { | |
| 28 | + | if base > RTL { | |
| 29 | + | return nil, 0, fmt.Errorf("invalid bidi base %d", base) | |
| 30 | + | } | |
| 31 | + | if len(text) == 0 { | |
| 32 | + | if len(lineBreaks) != 0 { | |
| 33 | + | return nil, 0, fmt.Errorf("breaks on empty paragraph") | |
| 34 | + | } | |
| 35 | + | if base == RTL { | |
| 36 | + | return nil, 1, nil | |
| 37 | + | } | |
| 38 | + | return nil, 0, nil | |
| 39 | + | } | |
| 40 | + | if len(lineBreaks) == 0 || lineBreaks[len(lineBreaks)-1] != len(text) { | |
| 41 | + | return nil, 0, fmt.Errorf("line breaks must end at paragraph length") | |
| 42 | + | } | |
| 43 | + | prev := 0 | |
| 44 | + | for _, end := range lineBreaks { | |
| 45 | + | if end <= prev || end > len(text) { | |
| 46 | + | return nil, 0, fmt.Errorf("invalid line break %d", end) | |
| 47 | + | } | |
| 48 | + | prev = end | |
| 49 | + | } | |
| 50 | + | classes := make([]Class, len(text)) | |
| 51 | + | pairTypes := make([]bracketType, len(text)) | |
| 52 | + | pairValues := make([]rune, len(text)) | |
| 53 | + | for i, r := range text { | |
| 54 | + | prop, _ := LookupRune(r) | |
| 55 | + | classes[i] = prop.Class() | |
| 56 | + | switch { | |
| 57 | + | case prop.IsOpeningBracket(): | |
| 58 | + | pairTypes[i] = bpOpen | |
| 59 | + | pairValues[i] = r | |
| 60 | + | // nefergui: U+2329/232A are canonically equivalent to U+3008/3009. | |
| 61 | + | if r == 0x2329 { | |
| 62 | + | pairValues[i] = 0x3008 | |
| 63 | + | } | |
| 64 | + | case prop.IsBracket(): | |
| 65 | + | pairTypes[i] = bpClose | |
| 66 | + | // nefergui: canonicalize close brackets to the opening identifier | |
| 67 | + | // required by upstream bracketPairer.matchOpener. | |
| 68 | + | pairValues[i] = prop.reverseBracket(r) | |
| 69 | + | if r == 0x232a { | |
| 70 | + | pairValues[i] = 0x3008 | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | embedding := implicitLevel | |
| 75 | + | if base == LTR { | |
| 76 | + | embedding = 0 | |
| 77 | + | } else if base == RTL { | |
| 78 | + | embedding = 1 | |
| 79 | + | } | |
| 80 | + | p, err := newParagraph(classes, pairTypes, pairValues, embedding) | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, 0, err | |
| 83 | + | } | |
| 84 | + | // nefergui: exercise the upstream L2 entry point as a differential oracle; | |
| 85 | + | // levels below are the same L1 result used by that entry point. | |
| 86 | + | _ = p.getReordering(lineBreaks) | |
| 87 | + | raw := p.getLevels(lineBreaks) | |
| 88 | + | out := make([]uint8, len(raw)) | |
| 89 | + | for i, l := range raw { | |
| 90 | + | out[i] = uint8(l) | |
| 91 | + | } | |
| 92 | + | return out, uint8(p.embeddingLevel), nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Reorder returns a visual-to-logical rune map per line using L2; X9-removed | |
| 96 | + | // formatting characters are excluded from the result. Levels must be the | |
| 97 | + | // per-line L1 output of Resolve. Consumers may group neighboring indices into | |
| 98 | + | // shaped runs while preserving the shaper's glyph order within each run. | |
| 99 | + | // nefergui: reuse upstream multiline L2 rather than infer levels from parity. | |
| 100 | + | func Reorder(text []rune, levels []uint8, breaks []int) ([][]int, error) { | |
| 101 | + | if len(text) != len(levels) { | |
| 102 | + | return nil, fmt.Errorf("level count mismatch") | |
| 103 | + | } | |
| 104 | + | if len(text) == 0 { | |
| 105 | + | return nil, nil | |
| 106 | + | } | |
| 107 | + | if len(breaks) == 0 || breaks[len(breaks)-1] != len(text) { | |
| 108 | + | return nil, fmt.Errorf("invalid breaks") | |
| 109 | + | } | |
| 110 | + | var out [][]int | |
| 111 | + | start := 0 | |
| 112 | + | for _, end := range breaks { | |
| 113 | + | if end <= start || end > len(text) { | |
| 114 | + | return nil, fmt.Errorf("invalid break %d", end) | |
| 115 | + | } | |
| 116 | + | lv := make([]level, end-start) | |
| 117 | + | for i, v := range levels[start:end] { | |
| 118 | + | lv[i] = level(v) | |
| 119 | + | } | |
| 120 | + | // nefergui: call upstream's multiline L2 helper to keep its code live. | |
| 121 | + | order := computeMultilineReordering(lv, []int{len(lv)}) | |
| 122 | + | line := make([]int, 0, len(order)) | |
| 123 | + | for _, i := range order { | |
| 124 | + | r := text[start+i] | |
| 125 | + | cls, _ := LookupRune(r) | |
| 126 | + | if !isRemovedByX9(cls.Class()) { | |
| 127 | + | line = append(line, start+i) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | out = append(out, line) | |
| 131 | + | start = end | |
| 132 | + | } | |
| 133 | + | return out, nil | |
| 134 | + | } | |
A internal/bidi/prop.go +206 -0
| @@ -0,0 +1,206 @@ | |||
| 1 | + | // Copyright 2016 The Go Authors. All rights reserved. | |
| 2 | + | // Use of this source code is governed by a BSD-style | |
| 3 | + | // license that can be found in the LICENSE file. | |
| 4 | + | ||
| 5 | + | package bidi | |
| 6 | + | ||
| 7 | + | import "unicode/utf8" | |
| 8 | + | ||
| 9 | + | // Properties provides access to BiDi properties of runes. | |
| 10 | + | type Properties struct { | |
| 11 | + | entry uint8 | |
| 12 | + | last uint8 | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var trie = newBidiTrie(0) | |
| 16 | + | ||
| 17 | + | // TODO: using this for bidirule reduces the running time by about 5%. Consider | |
| 18 | + | // if this is worth exposing or if we can find a way to speed up the Class | |
| 19 | + | // method. | |
| 20 | + | // | |
| 21 | + | // // CompactClass is like Class, but maps all of the BiDi control classes | |
| 22 | + | // // (LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI) to the class Control. | |
| 23 | + | // func (p Properties) CompactClass() Class { | |
| 24 | + | // return Class(p.entry & 0x0F) | |
| 25 | + | // } | |
| 26 | + | ||
| 27 | + | // Class returns the Bidi class for p. | |
| 28 | + | func (p Properties) Class() Class { | |
| 29 | + | c := Class(p.entry & 0x0F) | |
| 30 | + | if c == Control { | |
| 31 | + | c = controlByteToClass[p.last&0xF] | |
| 32 | + | } | |
| 33 | + | return c | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // IsBracket reports whether the rune is a bracket. | |
| 37 | + | func (p Properties) IsBracket() bool { return p.entry&0xF0 != 0 } | |
| 38 | + | ||
| 39 | + | // IsOpeningBracket reports whether the rune is an opening bracket. | |
| 40 | + | // IsBracket must return true. | |
| 41 | + | func (p Properties) IsOpeningBracket() bool { return p.entry&openMask != 0 } | |
| 42 | + | ||
| 43 | + | // TODO: find a better API and expose. | |
| 44 | + | func (p Properties) reverseBracket(r rune) rune { | |
| 45 | + | return xorMasks[p.entry>>xorMaskShift] ^ r | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var controlByteToClass = [16]Class{ | |
| 49 | + | 0xD: LRO, // U+202D LeftToRightOverride, | |
| 50 | + | 0xE: RLO, // U+202E RightToLeftOverride, | |
| 51 | + | 0xA: LRE, // U+202A LeftToRightEmbedding, | |
| 52 | + | 0xB: RLE, // U+202B RightToLeftEmbedding, | |
| 53 | + | 0xC: PDF, // U+202C PopDirectionalFormat, | |
| 54 | + | 0x6: LRI, // U+2066 LeftToRightIsolate, | |
| 55 | + | 0x7: RLI, // U+2067 RightToLeftIsolate, | |
| 56 | + | 0x8: FSI, // U+2068 FirstStrongIsolate, | |
| 57 | + | 0x9: PDI, // U+2069 PopDirectionalIsolate, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // LookupRune returns properties for r. | |
| 61 | + | func LookupRune(r rune) (p Properties, size int) { | |
| 62 | + | var buf [4]byte | |
| 63 | + | n := utf8.EncodeRune(buf[:], r) | |
| 64 | + | return Lookup(buf[:n]) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // TODO: these lookup methods are based on the generated trie code. The returned | |
| 68 | + | // sizes have slightly different semantics from the generated code, in that it | |
| 69 | + | // always returns size==1 for an illegal UTF-8 byte (instead of the length | |
| 70 | + | // of the maximum invalid subsequence). Most Transformers, like unicode/norm, | |
| 71 | + | // leave invalid UTF-8 untouched, in which case it has performance benefits to | |
| 72 | + | // do so (without changing the semantics). Bidi requires the semantics used here | |
| 73 | + | // for the bidirule implementation to be compatible with the Go semantics. | |
| 74 | + | // They ultimately should perhaps be adopted by all trie implementations, for | |
| 75 | + | // convenience sake. | |
| 76 | + | // This unrolled code also boosts performance of the secure/bidirule package by | |
| 77 | + | // about 30%. | |
| 78 | + | // So, to remove this code: | |
| 79 | + | // - add option to trie generator to define return type. | |
| 80 | + | // - always return 1 byte size for ill-formed UTF-8 runes. | |
| 81 | + | ||
| 82 | + | // Lookup returns properties for the first rune in s and the width in bytes of | |
| 83 | + | // its encoding. The size will be 0 if s does not hold enough bytes to complete | |
| 84 | + | // the encoding. | |
| 85 | + | func Lookup(s []byte) (p Properties, sz int) { | |
| 86 | + | c0 := s[0] | |
| 87 | + | switch { | |
| 88 | + | case c0 < 0x80: // is ASCII | |
| 89 | + | return Properties{entry: bidiValues[c0]}, 1 | |
| 90 | + | case c0 < 0xC2: | |
| 91 | + | return Properties{}, 1 | |
| 92 | + | case c0 < 0xE0: // 2-byte UTF-8 | |
| 93 | + | if len(s) < 2 { | |
| 94 | + | return Properties{}, 0 | |
| 95 | + | } | |
| 96 | + | i := bidiIndex[c0] | |
| 97 | + | c1 := s[1] | |
| 98 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 99 | + | return Properties{}, 1 | |
| 100 | + | } | |
| 101 | + | return Properties{entry: trie.lookupValue(uint32(i), c1)}, 2 | |
| 102 | + | case c0 < 0xF0: // 3-byte UTF-8 | |
| 103 | + | if len(s) < 3 { | |
| 104 | + | return Properties{}, 0 | |
| 105 | + | } | |
| 106 | + | i := bidiIndex[c0] | |
| 107 | + | c1 := s[1] | |
| 108 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 109 | + | return Properties{}, 1 | |
| 110 | + | } | |
| 111 | + | o := uint32(i)<<6 + uint32(c1) | |
| 112 | + | i = bidiIndex[o] | |
| 113 | + | c2 := s[2] | |
| 114 | + | if c2 < 0x80 || 0xC0 <= c2 { | |
| 115 | + | return Properties{}, 1 | |
| 116 | + | } | |
| 117 | + | return Properties{entry: trie.lookupValue(uint32(i), c2), last: c2}, 3 | |
| 118 | + | case c0 < 0xF8: // 4-byte UTF-8 | |
| 119 | + | if len(s) < 4 { | |
| 120 | + | return Properties{}, 0 | |
| 121 | + | } | |
| 122 | + | i := bidiIndex[c0] | |
| 123 | + | c1 := s[1] | |
| 124 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 125 | + | return Properties{}, 1 | |
| 126 | + | } | |
| 127 | + | o := uint32(i)<<6 + uint32(c1) | |
| 128 | + | i = bidiIndex[o] | |
| 129 | + | c2 := s[2] | |
| 130 | + | if c2 < 0x80 || 0xC0 <= c2 { | |
| 131 | + | return Properties{}, 1 | |
| 132 | + | } | |
| 133 | + | o = uint32(i)<<6 + uint32(c2) | |
| 134 | + | i = bidiIndex[o] | |
| 135 | + | c3 := s[3] | |
| 136 | + | if c3 < 0x80 || 0xC0 <= c3 { | |
| 137 | + | return Properties{}, 1 | |
| 138 | + | } | |
| 139 | + | return Properties{entry: trie.lookupValue(uint32(i), c3)}, 4 | |
| 140 | + | } | |
| 141 | + | // Illegal rune | |
| 142 | + | return Properties{}, 1 | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // LookupString returns properties for the first rune in s and the width in | |
| 146 | + | // bytes of its encoding. The size will be 0 if s does not hold enough bytes to | |
| 147 | + | // complete the encoding. | |
| 148 | + | func LookupString(s string) (p Properties, sz int) { | |
| 149 | + | c0 := s[0] | |
| 150 | + | switch { | |
| 151 | + | case c0 < 0x80: // is ASCII | |
| 152 | + | return Properties{entry: bidiValues[c0]}, 1 | |
| 153 | + | case c0 < 0xC2: | |
| 154 | + | return Properties{}, 1 | |
| 155 | + | case c0 < 0xE0: // 2-byte UTF-8 | |
| 156 | + | if len(s) < 2 { | |
| 157 | + | return Properties{}, 0 | |
| 158 | + | } | |
| 159 | + | i := bidiIndex[c0] | |
| 160 | + | c1 := s[1] | |
| 161 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 162 | + | return Properties{}, 1 | |
| 163 | + | } | |
| 164 | + | return Properties{entry: trie.lookupValue(uint32(i), c1)}, 2 | |
| 165 | + | case c0 < 0xF0: // 3-byte UTF-8 | |
| 166 | + | if len(s) < 3 { | |
| 167 | + | return Properties{}, 0 | |
| 168 | + | } | |
| 169 | + | i := bidiIndex[c0] | |
| 170 | + | c1 := s[1] | |
| 171 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 172 | + | return Properties{}, 1 | |
| 173 | + | } | |
| 174 | + | o := uint32(i)<<6 + uint32(c1) | |
| 175 | + | i = bidiIndex[o] | |
| 176 | + | c2 := s[2] | |
| 177 | + | if c2 < 0x80 || 0xC0 <= c2 { | |
| 178 | + | return Properties{}, 1 | |
| 179 | + | } | |
| 180 | + | return Properties{entry: trie.lookupValue(uint32(i), c2), last: c2}, 3 | |
| 181 | + | case c0 < 0xF8: // 4-byte UTF-8 | |
| 182 | + | if len(s) < 4 { | |
| 183 | + | return Properties{}, 0 | |
| 184 | + | } | |
| 185 | + | i := bidiIndex[c0] | |
| 186 | + | c1 := s[1] | |
| 187 | + | if c1 < 0x80 || 0xC0 <= c1 { | |
| 188 | + | return Properties{}, 1 | |
| 189 | + | } | |
| 190 | + | o := uint32(i)<<6 + uint32(c1) | |
| 191 | + | i = bidiIndex[o] | |
| 192 | + | c2 := s[2] | |
| 193 | + | if c2 < 0x80 || 0xC0 <= c2 { | |
| 194 | + | return Properties{}, 1 | |
| 195 | + | } | |
| 196 | + | o = uint32(i)<<6 + uint32(c2) | |
| 197 | + | i = bidiIndex[o] | |
| 198 | + | c3 := s[3] | |
| 199 | + | if c3 < 0x80 || 0xC0 <= c3 { | |
| 200 | + | return Properties{}, 1 | |
| 201 | + | } | |
| 202 | + | return Properties{entry: trie.lookupValue(uint32(i), c3)}, 4 | |
| 203 | + | } | |
| 204 | + | // Illegal rune | |
| 205 | + | return Properties{}, 1 | |
| 206 | + | } | |
A internal/bidi/tables15.0.0.go +2042 -0 large diff (2042 lines changed): collapsed
A internal/bidi/tables17.0.0.go +2135 -0 large diff (2135 lines changed): collapsed
A internal/bidi/testdata/BidiCharacterTest.txt +96465 -0 large diff (96465 lines changed): collapsed
A internal/bidi/testdata/LICENSE +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | UNICODE LICENSE V3 | |
| 2 | + | ||
| 3 | + | COPYRIGHT AND PERMISSION NOTICE | |
| 4 | + | ||
| 5 | + | Copyright © 1991-2026 Unicode, Inc. | |
| 6 | + | ||
| 7 | + | NOTICE TO USER: Carefully read the following legal agreement. BY | |
| 8 | + | DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR | |
| 9 | + | SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE | |
| 10 | + | TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT | |
| 11 | + | DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. | |
| 12 | + | ||
| 13 | + | Permission is hereby granted, free of charge, to any person obtaining a | |
| 14 | + | copy of data files and any associated documentation (the "Data Files") or | |
| 15 | + | software and any associated documentation (the "Software") to deal in the | |
| 16 | + | Data Files or Software without restriction, including without limitation | |
| 17 | + | the rights to use, copy, modify, merge, publish, distribute, and/or sell | |
| 18 | + | copies of the Data Files or Software, and to permit persons to whom the | |
| 19 | + | Data Files or Software are furnished to do so, provided that either (a) | |
| 20 | + | this copyright and permission notice appear with all copies of the Data | |
| 21 | + | Files or Software, or (b) this copyright and permission notice appear in | |
| 22 | + | associated Documentation. | |
| 23 | + | ||
| 24 | + | THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY | |
| 25 | + | KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| 26 | + | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF | |
| 27 | + | THIRD PARTY RIGHTS. | |
| 28 | + | ||
| 29 | + | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE | |
| 30 | + | BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, | |
| 31 | + | OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, | |
| 32 | + | WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, | |
| 33 | + | ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA | |
| 34 | + | FILES OR SOFTWARE. | |
| 35 | + | ||
| 36 | + | Except as contained in this notice, the name of a copyright holder shall | |
| 37 | + | not be used in advertising or otherwise to promote the sale, use or other | |
| 38 | + | dealings in these Data Files or Software without prior written | |
| 39 | + | authorization of the copyright holder. | |
A internal/bidi/trieval.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | // Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | package bidi | |
| 4 | + | ||
| 5 | + | // Class is the Unicode BiDi class. Each rune has a single class. | |
| 6 | + | type Class uint | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | L Class = iota // LeftToRight | |
| 10 | + | R // RightToLeft | |
| 11 | + | EN // EuropeanNumber | |
| 12 | + | ES // EuropeanSeparator | |
| 13 | + | ET // EuropeanTerminator | |
| 14 | + | AN // ArabicNumber | |
| 15 | + | CS // CommonSeparator | |
| 16 | + | B // ParagraphSeparator | |
| 17 | + | S // SegmentSeparator | |
| 18 | + | WS // WhiteSpace | |
| 19 | + | ON // OtherNeutral | |
| 20 | + | BN // BoundaryNeutral | |
| 21 | + | NSM // NonspacingMark | |
| 22 | + | AL // ArabicLetter | |
| 23 | + | Control // Control LRO - PDI | |
| 24 | + | ||
| 25 | + | numClass | |
| 26 | + | ||
| 27 | + | LRO // LeftToRightOverride | |
| 28 | + | RLO // RightToLeftOverride | |
| 29 | + | LRE // LeftToRightEmbedding | |
| 30 | + | RLE // RightToLeftEmbedding | |
| 31 | + | PDF // PopDirectionalFormat | |
| 32 | + | LRI // LeftToRightIsolate | |
| 33 | + | RLI // RightToLeftIsolate | |
| 34 | + | FSI // FirstStrongIsolate | |
| 35 | + | PDI // PopDirectionalIsolate | |
| 36 | + | ||
| 37 | + | unknownClass = ^Class(0) | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // A trie entry has the following bits: | |
| 41 | + | // 7..5 XOR mask for brackets | |
| 42 | + | // 4 1: Bracket open, 0: Bracket close | |
| 43 | + | // 3..0 Class type | |
| 44 | + | ||
| 45 | + | const ( | |
| 46 | + | openMask = 0x10 | |
| 47 | + | xorMaskShift = 5 | |
| 48 | + | ) | |
A internal/css/block_test.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestCommentsSeparateTokens(t *testing.T) { | |
| 6 | + | if got := Parse("button{color:r/*c*/ed; color:blue}"); got.Rules[0].Declarations[0].Value != "r ed" || Compile(Sheet{}, got).Compute(&Element{Type: "button"}, nil).Style.Color != namedColors["blue"] { | |
| 7 | + | t.Fatalf("comment joined value: %+v", got) | |
| 8 | + | } | |
| 9 | + | if got := Parse("bu/*c*/tton{color:red} button{color:blue}"); Compile(Sheet{}, got).Compute(&Element{Type: "button"}, nil).Style.Color != namedColors["blue"] { | |
| 10 | + | t.Fatalf("comment joined selector: %+v", got) | |
| 11 | + | } | |
| 12 | + | } | |
A internal/css/cascade.go +752 -0
| @@ -0,0 +1,752 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | _ "embed" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | //go:embed ua.css | |
| 9 | + | var uaSource string | |
| 10 | + | var uaSheet = Parse(uaSource) | |
| 11 | + | ||
| 12 | + | // UA returns the embedded user-agent sheet. Treat the returned sheet as immutable. | |
| 13 | + | func UA() Sheet { return uaSheet } | |
| 14 | + | ||
| 15 | + | // Declarations are parsed inline CSS. Do not modify them after passing them to Compute. | |
| 16 | + | type Declarations struct { | |
| 17 | + | compiled []compiledDecl | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // ParseInline parses a declaration list using the stylesheet declaration grammar. | |
| 21 | + | func ParseInline(src string) (*Declarations, []Diagnostic) { | |
| 22 | + | s := Parse("app{" + src + "}") | |
| 23 | + | d := &Declarations{} | |
| 24 | + | if len(s.Rules) > 0 { | |
| 25 | + | d.compiled = compileDecls(s.Rules[0].Declarations) | |
| 26 | + | } | |
| 27 | + | for i := range s.Diagnostics { | |
| 28 | + | if s.Diagnostics[i].Line == 1 { | |
| 29 | + | s.Diagnostics[i].Col -= len("app{") | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | return d, s.Diagnostics | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type compiledDecl struct { | |
| 36 | + | property propertyID | |
| 37 | + | custom string | |
| 38 | + | text string | |
| 39 | + | value Value | |
| 40 | + | parsed bool | |
| 41 | + | raw []Token | |
| 42 | + | group *pendingShorthand | |
| 43 | + | important bool | |
| 44 | + | order int | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // One pending substitution is shared by all of a shorthand's longhand winners. | |
| 48 | + | // Expanding and validating together prevents partial computed values. | |
| 49 | + | type pendingShorthand struct { | |
| 50 | + | property string | |
| 51 | + | text string | |
| 52 | + | targets []propertyID | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type compiledRule struct { | |
| 56 | + | selector Selector | |
| 57 | + | decls []compiledDecl | |
| 58 | + | origin int | |
| 59 | + | right compound | |
| 60 | + | } | |
| 61 | + | type candidate struct { | |
| 62 | + | decl *compiledDecl | |
| 63 | + | spec Specificity | |
| 64 | + | origin, order int | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func better(a, b candidate) bool { | |
| 68 | + | if b.decl == nil { | |
| 69 | + | return true | |
| 70 | + | } | |
| 71 | + | if a.decl.important != b.decl.important { | |
| 72 | + | return a.decl.important | |
| 73 | + | } | |
| 74 | + | if a.origin != b.origin { | |
| 75 | + | return a.origin > b.origin | |
| 76 | + | } | |
| 77 | + | if a.spec != b.spec { | |
| 78 | + | return a.spec.greater(b.spec) | |
| 79 | + | } | |
| 80 | + | return a.order >= b.order | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | type memoKey struct { | |
| 84 | + | parent *memoNode | |
| 85 | + | typ, id, class uint32 | |
| 86 | + | state StateFlags | |
| 87 | + | inline *Declarations | |
| 88 | + | } | |
| 89 | + | type memoNode struct { | |
| 90 | + | key memoKey | |
| 91 | + | style *Style | |
| 92 | + | custom map[string]string | |
| 93 | + | root float64 | |
| 94 | + | used uint64 | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Computed holds both the typed style and the identity needed for descendant matching. | |
| 98 | + | type Computed struct { | |
| 99 | + | Style *Style | |
| 100 | + | node *memoNode | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Engine is single-goroutine only; neither Compute nor EndFrame is synchronized. | |
| 104 | + | // Compiled rules never change. Compile a new Engine when CSS changes. | |
| 105 | + | type Engine struct { | |
| 106 | + | ids map[string]uint32 | |
| 107 | + | sets map[uint64][]classSet | |
| 108 | + | classMembers map[uint32][]uint32 | |
| 109 | + | nextID, nextSet uint32 | |
| 110 | + | usedIDs, usedSets map[uint32]bool | |
| 111 | + | rules []compiledRule | |
| 112 | + | byID, byClass, byType map[string][]int | |
| 113 | + | universal []int | |
| 114 | + | memo map[memoKey]*Computed | |
| 115 | + | scratch []*memoNode | |
| 116 | + | generation uint64 | |
| 117 | + | } | |
| 118 | + | type classSet struct { | |
| 119 | + | id uint32 | |
| 120 | + | members []uint32 | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Compile indexes the rightmost compound of every selector and freezes the sheets. | |
| 124 | + | func Compile(ua, author Sheet) *Engine { | |
| 125 | + | en := &Engine{ids: make(map[string]uint32), sets: make(map[uint64][]classSet), classMembers: make(map[uint32][]uint32), usedIDs: make(map[uint32]bool), usedSets: make(map[uint32]bool), byID: make(map[string][]int), byClass: make(map[string][]int), byType: make(map[string][]int), memo: make(map[memoKey]*Computed)} | |
| 126 | + | order := 0 | |
| 127 | + | for origin, sheet := range []Sheet{ua, author} { | |
| 128 | + | for _, r := range sheet.Rules { | |
| 129 | + | ds := compileDecls(r.Declarations) | |
| 130 | + | for i := range ds { | |
| 131 | + | ds[i].order += order | |
| 132 | + | } | |
| 133 | + | order += len(r.Declarations) | |
| 134 | + | for _, sel := range r.Selectors { | |
| 135 | + | sel.parts = append([]compound(nil), sel.parts...) | |
| 136 | + | for pi := range sel.parts { | |
| 137 | + | part := &sel.parts[pi] | |
| 138 | + | part.typID = en.intern(part.typ) | |
| 139 | + | part.idID = en.intern(part.id) | |
| 140 | + | for _, cl := range part.classes { | |
| 141 | + | part.classIDs = append(part.classIDs, en.intern(cl)) | |
| 142 | + | } | |
| 143 | + | for _, pseudo := range part.pseudos { | |
| 144 | + | part.state |= pseudoFlag(pseudo) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | i := len(en.rules) | |
| 148 | + | en.rules = append(en.rules, compiledRule{selector: sel, decls: ds, origin: origin, right: sel.parts[len(sel.parts)-1]}) | |
| 149 | + | right := sel.parts[len(sel.parts)-1] | |
| 150 | + | switch { | |
| 151 | + | case right.id != "": | |
| 152 | + | en.byID[right.id] = append(en.byID[right.id], i) | |
| 153 | + | case len(right.classes) > 0: | |
| 154 | + | en.byClass[right.classes[0]] = append(en.byClass[right.classes[0]], i) | |
| 155 | + | case right.typ != "": | |
| 156 | + | en.byType[right.typ] = append(en.byType[right.typ], i) | |
| 157 | + | default: | |
| 158 | + | en.universal = append(en.universal, i) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return en | |
| 164 | + | } | |
| 165 | + | func (en *Engine) intern(s string) uint32 { | |
| 166 | + | if s == "" { | |
| 167 | + | return 0 | |
| 168 | + | } | |
| 169 | + | if id := en.ids[s]; id != 0 { | |
| 170 | + | return id | |
| 171 | + | } | |
| 172 | + | en.nextID++ | |
| 173 | + | id := en.nextID | |
| 174 | + | en.ids[s] = id | |
| 175 | + | return id | |
| 176 | + | } | |
| 177 | + | func hashClasses(xs []string) uint64 { | |
| 178 | + | var sum uint64 | |
| 179 | + | for _, x := range xs { | |
| 180 | + | h := uint64(14695981039346656037) | |
| 181 | + | for i := 0; i < len(x); i++ { | |
| 182 | + | h ^= uint64(x[i]) | |
| 183 | + | h *= 1099511628211 | |
| 184 | + | } | |
| 185 | + | sum += h * 0x9e3779b97f4a7c15 | |
| 186 | + | } | |
| 187 | + | return sum | |
| 188 | + | } | |
| 189 | + | func (en *Engine) classes(xs []string) uint32 { | |
| 190 | + | if len(xs) == 0 { | |
| 191 | + | return 0 | |
| 192 | + | } | |
| 193 | + | h := hashClasses(xs) | |
| 194 | + | for _, set := range en.sets[h] { | |
| 195 | + | if len(set.members) != len(xs) { | |
| 196 | + | continue | |
| 197 | + | } | |
| 198 | + | ok := true | |
| 199 | + | for _, x := range xs { | |
| 200 | + | id := en.intern(x) | |
| 201 | + | found := false | |
| 202 | + | for _, v := range set.members { | |
| 203 | + | if v == id { | |
| 204 | + | found = true | |
| 205 | + | break | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if !found { | |
| 209 | + | ok = false | |
| 210 | + | break | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | if ok { | |
| 214 | + | return set.id | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | en.nextSet++ | |
| 218 | + | id := en.nextSet | |
| 219 | + | s := classSet{id: id, members: make([]uint32, 0, len(xs))} | |
| 220 | + | for _, x := range xs { | |
| 221 | + | s.members = append(s.members, en.intern(x)) | |
| 222 | + | } | |
| 223 | + | en.sets[h] = append(en.sets[h], s) | |
| 224 | + | en.classMembers[id] = s.members | |
| 225 | + | return id | |
| 226 | + | } | |
| 227 | + | func pseudoFlag(name string) StateFlags { | |
| 228 | + | switch name { | |
| 229 | + | case "hover": | |
| 230 | + | return Hover | |
| 231 | + | case "active": | |
| 232 | + | return Active | |
| 233 | + | case "focus": | |
| 234 | + | return Focus | |
| 235 | + | case "focus-visible": | |
| 236 | + | return FocusVisible | |
| 237 | + | case "disabled": | |
| 238 | + | return Disabled | |
| 239 | + | case "checked": | |
| 240 | + | return Checked | |
| 241 | + | } | |
| 242 | + | return 0 | |
| 243 | + | } | |
| 244 | + | func (en *Engine) matchPart(p compound, k memoKey) bool { | |
| 245 | + | if p.typID != 0 && p.typID != k.typ || p.idID != 0 && p.idID != k.id || p.state&k.state != p.state { | |
| 246 | + | return false | |
| 247 | + | } | |
| 248 | + | for _, id := range p.classIDs { | |
| 249 | + | found := false | |
| 250 | + | for _, member := range en.classMembers[k.class] { | |
| 251 | + | if member == id { | |
| 252 | + | found = true | |
| 253 | + | break | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | if !found { | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | return true | |
| 261 | + | } | |
| 262 | + | func (en *Engine) matchesAncestors(s Selector, ancestor *memoNode) bool { | |
| 263 | + | for i := len(s.parts) - 1; i > 0; { | |
| 264 | + | comb := s.parts[i].combinator | |
| 265 | + | i-- | |
| 266 | + | if comb == ">" { | |
| 267 | + | if ancestor == nil || !en.matchPart(s.parts[i], ancestor.key) { | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | ancestor = ancestor.key.parent | |
| 271 | + | } else { | |
| 272 | + | for ancestor != nil && !en.matchPart(s.parts[i], ancestor.key) { | |
| 273 | + | ancestor = ancestor.key.parent | |
| 274 | + | } | |
| 275 | + | if ancestor == nil { | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | ancestor = ancestor.key.parent | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return true | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Compute returns the same *Computed on memo hits. parent must come from this Engine. | |
| 285 | + | func (en *Engine) Compute(e *Element, parent *Computed) *Computed { | |
| 286 | + | var p *memoNode | |
| 287 | + | if parent != nil { | |
| 288 | + | p = parent.node | |
| 289 | + | } | |
| 290 | + | typ := strings.ToLower(e.Type) | |
| 291 | + | k := memoKey{parent: p, typ: en.intern(typ), id: en.intern(e.ID), class: en.classes(e.Classes), state: e.State, inline: e.Inline} | |
| 292 | + | if c := en.memo[k]; c != nil { | |
| 293 | + | c.node.used = en.generation | |
| 294 | + | return c | |
| 295 | + | } | |
| 296 | + | n := &memoNode{key: k, used: en.generation} | |
| 297 | + | var winners [propertyCount]candidate | |
| 298 | + | customWinners := map[string]candidate{} | |
| 299 | + | visit := func(indices []int) { | |
| 300 | + | for _, idx := range indices { | |
| 301 | + | r := &en.rules[idx] | |
| 302 | + | if !en.matchPart(r.right, k) || len(r.selector.parts) > 1 && !en.matchesAncestors(r.selector, k.parent) { | |
| 303 | + | continue | |
| 304 | + | } | |
| 305 | + | for di := range r.decls { | |
| 306 | + | d := &r.decls[di] | |
| 307 | + | c := candidate{decl: d, spec: r.selector.Specificity, origin: r.origin, order: d.order} | |
| 308 | + | if d.custom != "" { | |
| 309 | + | if better(c, customWinners[d.custom]) { | |
| 310 | + | customWinners[d.custom] = c | |
| 311 | + | } | |
| 312 | + | } else if better(c, winners[d.property]) { | |
| 313 | + | winners[d.property] = c | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | visit(en.universal) | |
| 319 | + | visit(en.byType[typ]) | |
| 320 | + | visit(en.byID[e.ID]) | |
| 321 | + | for _, cl := range e.Classes { | |
| 322 | + | visit(en.byClass[cl]) | |
| 323 | + | } | |
| 324 | + | if e.Inline != nil { | |
| 325 | + | for i := range e.Inline.compiled { | |
| 326 | + | d := &e.Inline.compiled[i] | |
| 327 | + | c := candidate{decl: d, origin: 2, order: d.order} | |
| 328 | + | if d.custom != "" { | |
| 329 | + | if better(c, customWinners[d.custom]) { | |
| 330 | + | customWinners[d.custom] = c | |
| 331 | + | } | |
| 332 | + | } else if better(c, winners[d.property]) { | |
| 333 | + | winners[d.property] = c | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if p != nil { | |
| 338 | + | n.custom = p.custom | |
| 339 | + | n.root = p.root | |
| 340 | + | } | |
| 341 | + | if len(customWinners) > 0 { | |
| 342 | + | n.custom = make(map[string]string, len(customWinners)+len(n.custom)) | |
| 343 | + | if p != nil { | |
| 344 | + | for key, value := range p.custom { | |
| 345 | + | n.custom[key] = value | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | for key, c := range customWinners { | |
| 349 | + | n.custom[key] = c.decl.text | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | style := new(Style) | |
| 353 | + | *style = initialStyle | |
| 354 | + | if p != nil { | |
| 355 | + | style.Color = p.style.Color | |
| 356 | + | style.FontFamily = p.style.FontFamily | |
| 357 | + | style.FontSize = p.style.FontSize | |
| 358 | + | style.FontWeight = p.style.FontWeight | |
| 359 | + | style.FontStyle = p.style.FontStyle | |
| 360 | + | style.LineHeight = p.style.LineHeight | |
| 361 | + | style.LineHeightNumber = p.style.LineHeightNumber | |
| 362 | + | style.LineHeightKind = p.style.LineHeightKind | |
| 363 | + | style.TextAlign = p.style.TextAlign | |
| 364 | + | style.LetterSpacing = p.style.LetterSpacing | |
| 365 | + | style.LetterSpacingNormal = p.style.LetterSpacingNormal | |
| 366 | + | style.Cursor = p.style.Cursor | |
| 367 | + | style.AccentColor = p.style.AccentColor | |
| 368 | + | } | |
| 369 | + | pending := map[*pendingShorthand]shorthandResult{} | |
| 370 | + | compute := func(id propertyID, font, root float64, current Color) Value { | |
| 371 | + | name := propertyNames[id] | |
| 372 | + | c := winners[id] | |
| 373 | + | if c.decl == nil { | |
| 374 | + | if isInheritedID(id) && p != nil { | |
| 375 | + | return p.style.value(id) | |
| 376 | + | } | |
| 377 | + | if id == p_color { | |
| 378 | + | return Value{Color: Color{A: 1}} | |
| 379 | + | } | |
| 380 | + | if isCurrentColorDefault(id) { | |
| 381 | + | return Value{Color: current} | |
| 382 | + | } | |
| 383 | + | return defaultValues[id] | |
| 384 | + | } | |
| 385 | + | text := c.decl.text | |
| 386 | + | if text == "inherit" || text == "unset" && isInheritedID(id) { | |
| 387 | + | if p != nil { | |
| 388 | + | return p.style.value(id) | |
| 389 | + | } | |
| 390 | + | text = initial(name) | |
| 391 | + | } else if text == "initial" || text == "unset" { | |
| 392 | + | if id != p_color && id != p_font_size { | |
| 393 | + | if isCurrentColorDefault(id) { | |
| 394 | + | return Value{Color: current} | |
| 395 | + | } | |
| 396 | + | return defaultValues[id] | |
| 397 | + | } | |
| 398 | + | text = initial(name) | |
| 399 | + | } | |
| 400 | + | if len(c.decl.raw) > 0 { | |
| 401 | + | var ok bool | |
| 402 | + | if c.decl.group != nil { | |
| 403 | + | g := c.decl.group | |
| 404 | + | if result, seen := pending[g]; seen { | |
| 405 | + | ok = result.ok | |
| 406 | + | if ok { | |
| 407 | + | text = result.texts[id] | |
| 408 | + | } | |
| 409 | + | } else { | |
| 410 | + | result := resolveShorthand(g, n.custom, current, font, root) | |
| 411 | + | pending[g] = result | |
| 412 | + | ok = result.ok | |
| 413 | + | if ok { | |
| 414 | + | text = result.texts[id] | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | } else { | |
| 418 | + | text, ok = resolveVars(text, n.custom, map[string]bool{}, 0) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | if !ok { | |
| 422 | + | if inherited(name) && p != nil { | |
| 423 | + | return p.style.value(id) | |
| 424 | + | } | |
| 425 | + | text = initial(name) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | if text != c.decl.text { | |
| 429 | + | if text == "inherit" || text == "unset" && isInheritedID(id) { | |
| 430 | + | if p != nil { | |
| 431 | + | return p.style.value(id) | |
| 432 | + | } | |
| 433 | + | text = initial(name) | |
| 434 | + | } | |
| 435 | + | if text == "initial" || text == "unset" { | |
| 436 | + | text = initial(name) | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | if text != c.decl.text && text == initial(name) && id != p_font_size && id != p_color { | |
| 440 | + | if isCurrentColorDefault(id) { | |
| 441 | + | return Value{Color: current} | |
| 442 | + | } | |
| 443 | + | return defaultValues[id] | |
| 444 | + | } | |
| 445 | + | if c.decl.parsed && text == c.decl.text { | |
| 446 | + | if staticIndependent(name, text) { | |
| 447 | + | return c.decl.value | |
| 448 | + | } | |
| 449 | + | if strings.EqualFold(text, "currentcolor") && (id == p_color || isCurrentColorDefault(id)) { | |
| 450 | + | return Value{Color: current} | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | v, ok := parseValue(name, text, current, font, root) | |
| 454 | + | if !ok { | |
| 455 | + | if inherited(name) && p != nil { | |
| 456 | + | return p.style.value(id) | |
| 457 | + | } | |
| 458 | + | if isCurrentColorDefault(id) { | |
| 459 | + | return Value{Color: current} | |
| 460 | + | } | |
| 461 | + | return defaultValues[id] | |
| 462 | + | } | |
| 463 | + | return v | |
| 464 | + | } | |
| 465 | + | font, root := 16.0, 16.0 | |
| 466 | + | current := Color{A: 1} | |
| 467 | + | if p != nil { | |
| 468 | + | font = p.style.FontSize.Value | |
| 469 | + | root = p.root | |
| 470 | + | current = p.style.Color | |
| 471 | + | } | |
| 472 | + | if winners[p_font_size].decl != nil { | |
| 473 | + | style.set(p_font_size, compute(p_font_size, font, root, current)) | |
| 474 | + | } | |
| 475 | + | font = style.FontSize.Value | |
| 476 | + | if p == nil { | |
| 477 | + | root = font | |
| 478 | + | } | |
| 479 | + | n.root = root | |
| 480 | + | if winners[p_color].decl != nil { | |
| 481 | + | style.set(p_color, compute(p_color, font, root, current)) | |
| 482 | + | } | |
| 483 | + | current = style.Color | |
| 484 | + | style.BorderColor = ColorSides{current, current, current, current} | |
| 485 | + | style.OutlineColor = current | |
| 486 | + | for id := propertyID(0); id < propertyCount; id++ { | |
| 487 | + | if id != p_color && id != p_font_size && winners[id].decl != nil { | |
| 488 | + | style.set(id, compute(id, font, root, current)) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | n.style = style | |
| 492 | + | ||
| 493 | + | c := &Computed{Style: style, node: n} | |
| 494 | + | en.memo[k] = c | |
| 495 | + | return c | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // EndFrame advances the generation and drops entries not used in the last two frames. | |
| 499 | + | func (en *Engine) EndFrame() { | |
| 500 | + | en.generation++ | |
| 501 | + | if en.generation < 2 { | |
| 502 | + | return | |
| 503 | + | } | |
| 504 | + | // Each recently used descendant pins its ancestor chain before sweeping. | |
| 505 | + | live := en.scratch[:0] | |
| 506 | + | for _, c := range en.memo { | |
| 507 | + | if c.node.used == en.generation-1 { | |
| 508 | + | live = append(live, c.node) | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | for _, n := range live { | |
| 512 | + | for p := n.key.parent; p != nil; p = p.key.parent { | |
| 513 | + | p.used = en.generation - 1 | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | for k, c := range en.memo { | |
| 517 | + | if c.node.used+2 <= en.generation { | |
| 518 | + | delete(en.memo, k) | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | // Keep selector IDs and every identity referenced by a surviving memo key. | |
| 522 | + | usedIDs, usedSets := en.usedIDs, en.usedSets | |
| 523 | + | clear(usedIDs) | |
| 524 | + | clear(usedSets) | |
| 525 | + | for _, r := range en.rules { | |
| 526 | + | for _, part := range r.selector.parts { | |
| 527 | + | usedIDs[part.typID], usedIDs[part.idID] = true, true | |
| 528 | + | for _, id := range part.classIDs { | |
| 529 | + | usedIDs[id] = true | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | for k := range en.memo { | |
| 534 | + | usedIDs[k.typ], usedIDs[k.id] = true, true | |
| 535 | + | usedSets[k.class] = true | |
| 536 | + | } | |
| 537 | + | for hash, bucket := range en.sets { | |
| 538 | + | kept := bucket[:0] | |
| 539 | + | for _, set := range bucket { | |
| 540 | + | if usedSets[set.id] { | |
| 541 | + | kept = append(kept, set) | |
| 542 | + | for _, id := range set.members { | |
| 543 | + | usedIDs[id] = true | |
| 544 | + | } | |
| 545 | + | } else { | |
| 546 | + | delete(en.classMembers, set.id) | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | if len(kept) == 0 { | |
| 550 | + | delete(en.sets, hash) | |
| 551 | + | } else { | |
| 552 | + | en.sets[hash] = kept | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | for name, id := range en.ids { | |
| 556 | + | if !usedIDs[id] { | |
| 557 | + | delete(en.ids, name) | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | clear(live) | |
| 561 | + | en.scratch = live[:0] | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | // Shorthand targets are the longhands affected even before var() is substituted. | |
| 565 | + | func shorthandTargets(name string) []string { | |
| 566 | + | switch name { | |
| 567 | + | case "margin": | |
| 568 | + | return []string{"margin-top", "margin-right", "margin-bottom", "margin-left"} | |
| 569 | + | case "padding": | |
| 570 | + | return []string{"padding-top", "padding-right", "padding-bottom", "padding-left"} | |
| 571 | + | case "border-width": | |
| 572 | + | return []string{"border-top-width", "border-right-width", "border-bottom-width", "border-left-width"} | |
| 573 | + | case "border-style": | |
| 574 | + | return []string{"border-top-style", "border-right-style", "border-bottom-style", "border-left-style"} | |
| 575 | + | case "border-color": | |
| 576 | + | return []string{"border-top-color", "border-right-color", "border-bottom-color", "border-left-color"} | |
| 577 | + | case "border-radius": | |
| 578 | + | return []string{"border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius"} | |
| 579 | + | case "background": | |
| 580 | + | return []string{"background-color"} | |
| 581 | + | case "border": | |
| 582 | + | return []string{"border-top-width", "border-top-style", "border-top-color", "border-right-width", "border-right-style", "border-right-color", "border-bottom-width", "border-bottom-style", "border-bottom-color", "border-left-width", "border-left-style", "border-left-color"} | |
| 583 | + | case "border-top": | |
| 584 | + | return []string{"border-top-width", "border-top-style", "border-top-color"} | |
| 585 | + | case "border-right": | |
| 586 | + | return []string{"border-right-width", "border-right-style", "border-right-color"} | |
| 587 | + | case "border-bottom": | |
| 588 | + | return []string{"border-bottom-width", "border-bottom-style", "border-bottom-color"} | |
| 589 | + | case "border-left": | |
| 590 | + | return []string{"border-left-width", "border-left-style", "border-left-color"} | |
| 591 | + | case "outline": | |
| 592 | + | return []string{"outline-width", "outline-style", "outline-color"} | |
| 593 | + | case "flex": | |
| 594 | + | return []string{"flex-grow", "flex-shrink", "flex-basis"} | |
| 595 | + | case "overflow": | |
| 596 | + | return []string{"overflow-x", "overflow-y"} | |
| 597 | + | case "gap": | |
| 598 | + | return []string{"row-gap", "column-gap"} | |
| 599 | + | } | |
| 600 | + | return nil | |
| 601 | + | } | |
| 602 | + | func staticIndependent(name, text string) bool { | |
| 603 | + | lower := strings.ToLower(text) | |
| 604 | + | // em/rem are resolved using the current element/root font. "em" in a font | |
| 605 | + | // family name does not affect its value but keeping that case dynamic is safe. | |
| 606 | + | return !strings.Contains(lower, "currentcolor") && !strings.Contains(lower, "em") && name != "font-size" && name != "box-shadow" && name != "line-height" | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | // Initial values independent of color and font metrics, prepared once rather than parsed per node. | |
| 610 | + | var defaultValues = func() [propertyCount]Value { | |
| 611 | + | var values [propertyCount]Value | |
| 612 | + | for i := propertyID(0); i < propertyCount; i++ { | |
| 613 | + | values[i], _ = parseValue(propertyNames[i], initial(propertyNames[i]), Color{A: 1}, 16, 16) | |
| 614 | + | } | |
| 615 | + | return values | |
| 616 | + | }() | |
| 617 | + | ||
| 618 | + | func isCurrentColorDefault(id propertyID) bool { | |
| 619 | + | return id == p_outline_color || id == p_border_top_color || id == p_border_right_color || id == p_border_bottom_color || id == p_border_left_color | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | func isInheritedID(id propertyID) bool { | |
| 623 | + | switch id { | |
| 624 | + | case p_color, p_font_family, p_font_size, p_font_weight, p_font_style, p_line_height, p_text_align, p_letter_spacing, p_cursor, p_accent_color: | |
| 625 | + | return true | |
| 626 | + | } | |
| 627 | + | return false | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | func compileDecls(declarations []Declaration) []compiledDecl { | |
| 631 | + | var result []compiledDecl | |
| 632 | + | for i, d := range declarations { | |
| 633 | + | if strings.HasPrefix(d.Property, "--") { | |
| 634 | + | result = append(result, compiledDecl{custom: d.Property, text: d.Value, important: d.Important, order: i}) | |
| 635 | + | continue | |
| 636 | + | } | |
| 637 | + | expanded, ok := expand(d) | |
| 638 | + | if d.Value == "inherit" || d.Value == "initial" || d.Value == "unset" { | |
| 639 | + | if targets := shorthandTargets(d.Property); len(targets) > 0 { | |
| 640 | + | expanded = nil | |
| 641 | + | for _, name := range targets { | |
| 642 | + | expanded = append(expanded, Declaration{Property: name, Value: d.Value, Important: d.Important}) | |
| 643 | + | } | |
| 644 | + | ok = true | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | hasVar := containsVar(d.Value) | |
| 648 | + | if targets := shorthandTargets(d.Property); !hasVar && len(targets) > 0 && ok { | |
| 649 | + | for _, one := range expanded { | |
| 650 | + | if one.Value != "inherit" && one.Value != "initial" && one.Value != "unset" { | |
| 651 | + | if _, valid := parseValue(one.Property, one.Value, Color{A: 1}, 16, 16); !valid { | |
| 652 | + | ok = false | |
| 653 | + | break | |
| 654 | + | } | |
| 655 | + | } | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | if targets := shorthandTargets(d.Property); hasVar && len(targets) > 0 { | |
| 659 | + | expanded = nil | |
| 660 | + | for _, name := range targets { | |
| 661 | + | expanded = append(expanded, Declaration{Property: name, Value: d.Value, Important: d.Important}) | |
| 662 | + | } | |
| 663 | + | ok = true | |
| 664 | + | } | |
| 665 | + | if !ok { | |
| 666 | + | continue | |
| 667 | + | } | |
| 668 | + | var group *pendingShorthand | |
| 669 | + | var groupRaw []Token | |
| 670 | + | if hasVar && len(shorthandTargets(d.Property)) > 0 { | |
| 671 | + | groupRaw = Tokenize(d.Value) | |
| 672 | + | group = &pendingShorthand{property: d.Property, text: d.Value} | |
| 673 | + | for _, one := range expanded { | |
| 674 | + | if id, exists := propertyIndex(one.Property); exists { | |
| 675 | + | group.targets = append(group.targets, id) | |
| 676 | + | } | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | for _, one := range expanded { | |
| 680 | + | p, exists := propertyIndex(one.Property) | |
| 681 | + | if !exists { | |
| 682 | + | continue | |
| 683 | + | } | |
| 684 | + | cd := compiledDecl{property: p, text: one.Value, important: one.Important, order: i} | |
| 685 | + | if hasVar { | |
| 686 | + | cd.group = group | |
| 687 | + | if group == nil { | |
| 688 | + | cd.raw = Tokenize(one.Value) | |
| 689 | + | } else { | |
| 690 | + | cd.raw = groupRaw | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | if !hasVar && one.Value != "inherit" && one.Value != "initial" && one.Value != "unset" { | |
| 694 | + | var valid bool | |
| 695 | + | cd.value, valid = parseValue(one.Property, one.Value, Color{A: 1}, 16, 16) | |
| 696 | + | if !valid { | |
| 697 | + | continue | |
| 698 | + | } | |
| 699 | + | cd.parsed = true | |
| 700 | + | } | |
| 701 | + | result = append(result, cd) | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | return result | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | var initialStyle = func() Style { | |
| 708 | + | var s Style | |
| 709 | + | for i := propertyID(0); i < propertyCount; i++ { | |
| 710 | + | s.set(i, defaultValues[i]) | |
| 711 | + | } | |
| 712 | + | return s | |
| 713 | + | }() | |
| 714 | + | ||
| 715 | + | type shorthandResult struct { | |
| 716 | + | texts map[propertyID]string | |
| 717 | + | ok bool | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | func resolveShorthand(g *pendingShorthand, custom map[string]string, current Color, font, root float64) shorthandResult { | |
| 721 | + | text, ok := resolveVars(g.text, custom, map[string]bool{}, 0) | |
| 722 | + | if !ok { | |
| 723 | + | return shorthandResult{} | |
| 724 | + | } | |
| 725 | + | result := shorthandResult{texts: make(map[propertyID]string, len(g.targets)), ok: true} | |
| 726 | + | if text == "inherit" || text == "initial" || text == "unset" { | |
| 727 | + | for _, id := range g.targets { | |
| 728 | + | result.texts[id] = text | |
| 729 | + | } | |
| 730 | + | return result | |
| 731 | + | } | |
| 732 | + | ds, ok := expand(Declaration{Property: g.property, Value: text}) | |
| 733 | + | if !ok { | |
| 734 | + | return shorthandResult{} | |
| 735 | + | } | |
| 736 | + | for _, d := range ds { | |
| 737 | + | id, exists := propertyIndex(d.Property) | |
| 738 | + | if !exists { | |
| 739 | + | return shorthandResult{} | |
| 740 | + | } | |
| 741 | + | if _, valid := parseValue(d.Property, d.Value, current, font, root); !valid { | |
| 742 | + | return shorthandResult{} | |
| 743 | + | } | |
| 744 | + | result.texts[id] = d.Value | |
| 745 | + | } | |
| 746 | + | for _, id := range g.targets { | |
| 747 | + | if _, exists := result.texts[id]; !exists { | |
| 748 | + | return shorthandResult{} | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | return result | |
| 752 | + | } | |
A internal/css/colors.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import "strconv" | |
| 4 | + | ||
| 5 | + | // CSS named colors (CSS Color 3 + rebeccapurple). | |
| 6 | + | var namedColors = func() map[string]Color { | |
| 7 | + | m := map[string]Color{} | |
| 8 | + | for name, hex := range map[string]string{ | |
| 9 | + | "black": "#000000", | |
| 10 | + | "silver": "#c0c0c0", | |
| 11 | + | "gray": "#808080", | |
| 12 | + | "white": "#FFFFFF", | |
| 13 | + | "maroon": "#800000", | |
| 14 | + | "red": "#FF0000", | |
| 15 | + | "purple": "#800080", | |
| 16 | + | "fuchsia": "#FF00FF", | |
| 17 | + | "green": "#008000", | |
| 18 | + | "lime": "#00FF00", | |
| 19 | + | "olive": "#808000", | |
| 20 | + | "yellow": "#FFFF00", | |
| 21 | + | "navy": "#000080", | |
| 22 | + | "blue": "#0000FF", | |
| 23 | + | "teal": "#008080", | |
| 24 | + | "aqua": "#00FFFF", | |
| 25 | + | "aliceblue": "#f0f8ff", | |
| 26 | + | "antiquewhite": "#faebd7", | |
| 27 | + | "aquamarine": "#7fffd4", | |
| 28 | + | "azure": "#f0ffff", | |
| 29 | + | "beige": "#f5f5dc", | |
| 30 | + | "bisque": "#ffe4c4", | |
| 31 | + | "blanchedalmond": "#ffebcd", | |
| 32 | + | "blueviolet": "#8a2be2", | |
| 33 | + | "brown": "#a52a2a", | |
| 34 | + | "burlywood": "#deb887", | |
| 35 | + | "cadetblue": "#5f9ea0", | |
| 36 | + | "chartreuse": "#7fff00", | |
| 37 | + | "chocolate": "#d2691e", | |
| 38 | + | "coral": "#ff7f50", | |
| 39 | + | "cornflowerblue": "#6495ed", | |
| 40 | + | "cornsilk": "#fff8dc", | |
| 41 | + | "crimson": "#dc143c", | |
| 42 | + | "cyan": "#00ffff", | |
| 43 | + | "darkblue": "#00008b", | |
| 44 | + | "darkcyan": "#008b8b", | |
| 45 | + | "darkgoldenrod": "#b8860b", | |
| 46 | + | "darkgray": "#a9a9a9", | |
| 47 | + | "darkgreen": "#006400", | |
| 48 | + | "darkgrey": "#a9a9a9", | |
| 49 | + | "darkkhaki": "#bdb76b", | |
| 50 | + | "darkmagenta": "#8b008b", | |
| 51 | + | "darkolivegreen": "#556b2f", | |
| 52 | + | "darkorange": "#ff8c00", | |
| 53 | + | "darkorchid": "#9932cc", | |
| 54 | + | "darkred": "#8b0000", | |
| 55 | + | "darksalmon": "#e9967a", | |
| 56 | + | "darkseagreen": "#8fbc8f", | |
| 57 | + | "darkslateblue": "#483d8b", | |
| 58 | + | "darkslategray": "#2f4f4f", | |
| 59 | + | "darkslategrey": "#2f4f4f", | |
| 60 | + | "darkturquoise": "#00ced1", | |
| 61 | + | "darkviolet": "#9400d3", | |
| 62 | + | "deeppink": "#ff1493", | |
| 63 | + | "deepskyblue": "#00bfff", | |
| 64 | + | "dimgray": "#696969", | |
| 65 | + | "dimgrey": "#696969", | |
| 66 | + | "dodgerblue": "#1e90ff", | |
| 67 | + | "firebrick": "#b22222", | |
| 68 | + | "floralwhite": "#fffaf0", | |
| 69 | + | "forestgreen": "#228b22", | |
| 70 | + | "gainsboro": "#dcdcdc", | |
| 71 | + | "ghostwhite": "#f8f8ff", | |
| 72 | + | "gold": "#ffd700", | |
| 73 | + | "goldenrod": "#daa520", | |
| 74 | + | "greenyellow": "#adff2f", | |
| 75 | + | "grey": "#808080", | |
| 76 | + | "honeydew": "#f0fff0", | |
| 77 | + | "hotpink": "#ff69b4", | |
| 78 | + | "indianred": "#cd5c5c", | |
| 79 | + | "indigo": "#4b0082", | |
| 80 | + | "ivory": "#fffff0", | |
| 81 | + | "khaki": "#f0e68c", | |
| 82 | + | "lavender": "#e6e6fa", | |
| 83 | + | "lavenderblush": "#fff0f5", | |
| 84 | + | "lawngreen": "#7cfc00", | |
| 85 | + | "lemonchiffon": "#fffacd", | |
| 86 | + | "lightblue": "#add8e6", | |
| 87 | + | "lightcoral": "#f08080", | |
| 88 | + | "lightcyan": "#e0ffff", | |
| 89 | + | "lightgoldenrodyellow": "#fafad2", | |
| 90 | + | "lightgray": "#d3d3d3", | |
| 91 | + | "lightgreen": "#90ee90", | |
| 92 | + | "lightgrey": "#d3d3d3", | |
| 93 | + | "lightpink": "#ffb6c1", | |
| 94 | + | "lightsalmon": "#ffa07a", | |
| 95 | + | "lightseagreen": "#20b2aa", | |
| 96 | + | "lightskyblue": "#87cefa", | |
| 97 | + | "lightslategray": "#778899", | |
| 98 | + | "lightslategrey": "#778899", | |
| 99 | + | "lightsteelblue": "#b0c4de", | |
| 100 | + | "lightyellow": "#ffffe0", | |
| 101 | + | "limegreen": "#32cd32", | |
| 102 | + | "linen": "#faf0e6", | |
| 103 | + | "magenta": "#ff00ff", | |
| 104 | + | "mediumaquamarine": "#66cdaa", | |
| 105 | + | "mediumblue": "#0000cd", | |
| 106 | + | "mediumorchid": "#ba55d3", | |
| 107 | + | "mediumpurple": "#9370db", | |
| 108 | + | "mediumseagreen": "#3cb371", | |
| 109 | + | "mediumslateblue": "#7b68ee", | |
| 110 | + | "mediumspringgreen": "#00fa9a", | |
| 111 | + | "mediumturquoise": "#48d1cc", | |
| 112 | + | "mediumvioletred": "#c71585", | |
| 113 | + | "midnightblue": "#191970", | |
| 114 | + | "mintcream": "#f5fffa", | |
| 115 | + | "mistyrose": "#ffe4e1", | |
| 116 | + | "moccasin": "#ffe4b5", | |
| 117 | + | "navajowhite": "#ffdead", | |
| 118 | + | "oldlace": "#fdf5e6", | |
| 119 | + | "olivedrab": "#6b8e23", | |
| 120 | + | "orange": "#ffa500", | |
| 121 | + | "orangered": "#ff4500", | |
| 122 | + | "orchid": "#da70d6", | |
| 123 | + | "palegoldenrod": "#eee8aa", | |
| 124 | + | "palegreen": "#98fb98", | |
| 125 | + | "paleturquoise": "#afeeee", | |
| 126 | + | "palevioletred": "#db7093", | |
| 127 | + | "papayawhip": "#ffefd5", | |
| 128 | + | "peachpuff": "#ffdab9", | |
| 129 | + | "peru": "#cd853f", | |
| 130 | + | "pink": "#ffc0cb", | |
| 131 | + | "plum": "#dda0dd", | |
| 132 | + | "powderblue": "#b0e0e6", | |
| 133 | + | "rosybrown": "#bc8f8f", | |
| 134 | + | "royalblue": "#4169e1", | |
| 135 | + | "saddlebrown": "#8b4513", | |
| 136 | + | "salmon": "#fa8072", | |
| 137 | + | "sandybrown": "#f4a460", | |
| 138 | + | "seagreen": "#2e8b57", | |
| 139 | + | "seashell": "#fff5ee", | |
| 140 | + | "sienna": "#a0522d", | |
| 141 | + | "skyblue": "#87ceeb", | |
| 142 | + | "slateblue": "#6a5acd", | |
| 143 | + | "slategray": "#708090", | |
| 144 | + | "slategrey": "#708090", | |
| 145 | + | "snow": "#fffafa", | |
| 146 | + | "springgreen": "#00ff7f", | |
| 147 | + | "steelblue": "#4682b4", | |
| 148 | + | "tan": "#d2b48c", | |
| 149 | + | "thistle": "#d8bfd8", | |
| 150 | + | "tomato": "#ff6347", | |
| 151 | + | "turquoise": "#40e0d0", | |
| 152 | + | "violet": "#ee82ee", | |
| 153 | + | "wheat": "#f5deb3", | |
| 154 | + | "whitesmoke": "#f5f5f5", | |
| 155 | + | "yellowgreen": "#9acd32", | |
| 156 | + | "rebeccapurple": "#663399", | |
| 157 | + | } { | |
| 158 | + | m[name] = hexColor(hex) | |
| 159 | + | } | |
| 160 | + | return m | |
| 161 | + | }() | |
| 162 | + | ||
| 163 | + | func hexColor(hex string) Color { | |
| 164 | + | var v [3]uint64 | |
| 165 | + | for i := range v { | |
| 166 | + | v[i], _ = strconv.ParseUint(hex[1+i*2:3+i*2], 16, 8) | |
| 167 | + | } | |
| 168 | + | return Color{float64(v[0]) / 255, float64(v[1]) / 255, float64(v[2]) / 255, 1} | |
| 169 | + | } | |
A internal/css/css_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFixture(t *testing.T) { | |
| 11 | + | src, e := os.ReadFile("testdata/basic.css") | |
| 12 | + | if e != nil { | |
| 13 | + | t.Fatal(e) | |
| 14 | + | } | |
| 15 | + | want, e := os.ReadFile("testdata/basic.json") | |
| 16 | + | if e != nil { | |
| 17 | + | t.Fatal(e) | |
| 18 | + | } | |
| 19 | + | root := Element{Type: "app"} | |
| 20 | + | child := Element{Type: "row"} | |
| 21 | + | sheet := Parse(string(src)) | |
| 22 | + | engine := Compile(UA(), sheet) | |
| 23 | + | p := engine.Compute(&root, nil) | |
| 24 | + | got := engine.Compute(&child, p).Style | |
| 25 | + | v := map[string]any{"display": keywordNames[got.Display], "padding-top": got.Padding.Top, "padding-right": got.Padding.Right, "background-color": got.BackgroundColor, "font-size": got.FontSize} | |
| 26 | + | b, _ := json.Marshal(v) | |
| 27 | + | var a, c any | |
| 28 | + | _ = json.Unmarshal(b, &a) | |
| 29 | + | _ = json.Unmarshal(want, &c) | |
| 30 | + | if !reflect.DeepEqual(a, c) { | |
| 31 | + | t.Fatalf("got %s want %s", b, want) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | func TestColors(t *testing.T) { | |
| 35 | + | tests := []struct { | |
| 36 | + | s string | |
| 37 | + | c Color | |
| 38 | + | }{{"#f00", Color{1, 0, 0, 1}}, {"#f008", Color{1, 0, 0, 136.0 / 255}}, {"#ff0000", Color{1, 0, 0, 1}}, {"#ff000080", Color{1, 0, 0, 128.0 / 255}}, {"rgb(255,0,0)", Color{1, 0, 0, 1}}, {"rgba(255 0 0 / 50%)", Color{1, 0, 0, .5}}, {"hsl(0 100% 50%)", Color{1, 0, 0, 1}}, {"hsla(0,100%,50%,.5)", Color{1, 0, 0, .5}}, {"red", Color{1, 0, 0, 1}}, {"transparent", Color{}}} | |
| 39 | + | for _, tt := range tests { | |
| 40 | + | t.Run(tt.s, func(t *testing.T) { | |
| 41 | + | v, ok := parseColor(tt.s, Color{}) | |
| 42 | + | if !ok || v != tt.c { | |
| 43 | + | t.Fatalf("%v %v", v, ok) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | func TestCascade(t *testing.T) { | |
| 49 | + | tests := []struct { | |
| 50 | + | css, typ, id string | |
| 51 | + | hover bool | |
| 52 | + | want string | |
| 53 | + | }{{"row{display:block}", "row", "", false, "block"}, {"row{display:none} row{display:flex}", "row", "", false, "flex"}, {"#x {display:none} row {display:flex}", "row", "x", false, "none"}, {"row:hover{display:none} row{display:flex}", "row", "", true, "none"}, {"row{display:none!important} #x{display:flex}", "row", "x", false, "none"}} | |
| 54 | + | for _, tt := range tests { | |
| 55 | + | t.Run(tt.css, func(t *testing.T) { | |
| 56 | + | e := Element{Type: tt.typ, ID: tt.id} | |
| 57 | + | if tt.hover { | |
| 58 | + | e.State = Hover | |
| 59 | + | } | |
| 60 | + | s := Compile(UA(), Parse(tt.css)).Compute(&e, nil).Style | |
| 61 | + | if keywordNames[s.Display] != tt.want { | |
| 62 | + | t.Fatalf("got %s want %s", keywordNames[s.Display], tt.want) | |
| 63 | + | } | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | func TestInvalidAndInheritance(t *testing.T) { | |
| 68 | + | p := Element{Type: "app"} | |
| 69 | + | e := Element{Type: "button"} | |
| 70 | + | sheet := Parse("app {color: blue; font-size: 20px; --a: var(--b); --b: var(--a)} button { color: var(--a); padding: 1em 2px; opacity: 1e999; unknown: 1; background: var(--a, red)}") | |
| 71 | + | engine := Compile(UA(), sheet) | |
| 72 | + | ps := engine.Compute(&p, nil) | |
| 73 | + | s := engine.Compute(&e, ps).Style | |
| 74 | + | if s.Color != ps.Style.Color || s.Padding.Top.Value != 20 || s.Opacity != 1 || s.BackgroundColor.R != 1 { | |
| 75 | + | t.Fatalf("style %#v", s) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | func TestRecovery(t *testing.T) { | |
| 79 | + | s := Parse("button { wat; color:red } bad:hover::foo {color:blue} button { color: green }") | |
| 80 | + | if len(s.Rules) != 2 || len(s.Diagnostics) < 2 { | |
| 81 | + | t.Fatalf("%+v", s) | |
| 82 | + | } | |
| 83 | + | e := Element{Type: "button"} | |
| 84 | + | v := Compile(UA(), s).Compute(&e, nil).Style | |
| 85 | + | if v.Color.G != namedColors["green"].G { | |
| 86 | + | t.Fatal(v.Color) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | func TestSelector(t *testing.T) { | |
| 90 | + | p := Element{Type: "app"} | |
| 91 | + | e := Element{Type: "button", Classes: []string{"ok"}, State: FocusVisible | Checked} | |
| 92 | + | for _, tt := range []struct { | |
| 93 | + | s string | |
| 94 | + | match bool | |
| 95 | + | }{{"app > button.ok:focus-visible", true}, {"app button", true}, {"row > button", false}, {"app > button:disabled", false}, {"button:checked", true}} { | |
| 96 | + | s := Parse(tt.s + " {color:red}") | |
| 97 | + | en := Compile(Sheet{}, s) | |
| 98 | + | pv := en.Compute(&p, nil) | |
| 99 | + | got := en.Compute(&e, pv).Style.Color == namedColors["red"] | |
| 100 | + | if len(s.Rules) != 1 || got != tt.match { | |
| 101 | + | t.Fatalf("%s %+v", tt.s, s) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | func FuzzTokenizerParser(f *testing.F) { | |
| 106 | + | for _, s := range []string{"a { color:red }", "\xff{bad:'\n; background: rgb(1,2,3)}", "/*", "a{--x:var(--x); color:var(--x)}"} { | |
| 107 | + | f.Add(s) | |
| 108 | + | } | |
| 109 | + | f.Fuzz(func(t *testing.T, s string) { | |
| 110 | + | if len(s) > 1<<16 { | |
| 111 | + | t.Skip() | |
| 112 | + | } | |
| 113 | + | _ = Tokenize(s) | |
| 114 | + | sheet := Parse(s) | |
| 115 | + | e := Element{Type: "a"} | |
| 116 | + | _ = Compile(UA(), sheet).Compute(&e, nil) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | func TestHugeNumbers(t *testing.T) { | |
| 120 | + | for _, v := range []string{"1e999", "NaN", "Inf", "1e308em"} { | |
| 121 | + | e := Element{Type: "app"} | |
| 122 | + | s := Compile(UA(), Parse("app{width:"+v+";font-size:"+v+"; opacity:"+v+"}")).Compute(&e, nil).Style | |
| 123 | + | if s.Width.Unit != "auto" || s.FontSize.Value != 16 || s.Opacity != 1 { | |
| 124 | + | t.Fatalf("%s: %+v", v, s) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | func TestTokenRecovery(t *testing.T) { | |
| 129 | + | ts := Tokenize("a {color:'broken\n; background:url(foo); width:2px}") | |
| 130 | + | found := false | |
| 131 | + | for _, tok := range ts { | |
| 132 | + | if tok.Kind == "bad-string" { | |
| 133 | + | found = true | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if !found { | |
| 137 | + | t.Fatal(ts) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | func TestEscapesAndBadURL(t *testing.T) { | |
| 141 | + | for _, tt := range []struct{ src, kind, value string }{{`b\75 tton`, "ident", "button"}, {`#\78 `, "hash", "x"}, {`url(foo)`, "url", "foo"}, {`url(foo"bar);a`, "bad-url", "foo\"bar"}} { | |
| 142 | + | ts := Tokenize(tt.src) | |
| 143 | + | if len(ts) == 0 || ts[0].Kind != tt.kind || ts[0].Value != tt.value { | |
| 144 | + | t.Errorf("%q: %+v", tt.src, ts) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | e := Element{Type: "button"} | |
| 148 | + | s := Parse(`b\75 tton {color:red}`) | |
| 149 | + | if len(s.Rules) != 1 || Compile(Sheet{}, s).Compute(&e, nil).Style.Color != namedColors["red"] { | |
| 150 | + | t.Fatal(s) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | func TestRecoveryNested(t *testing.T) { | |
| 154 | + | tests := []struct { | |
| 155 | + | src string | |
| 156 | + | rules, decls int | |
| 157 | + | }{{`button {broken: fn(a;b); color: red;}`, 1, 2}, {`button {bad { x:y; }; color:red} row{display:flex}`, 2, 1}, {`button:unknown { color:red } button {color:blue}`, 1, 1}, {`button { color:red`, 0, 0}} | |
| 158 | + | for _, tt := range tests { | |
| 159 | + | s := Parse(tt.src) | |
| 160 | + | if len(s.Rules) != tt.rules { | |
| 161 | + | t.Errorf("%q rules=%d diagnostics=%v", tt.src, len(s.Rules), s.Diagnostics) | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | if tt.rules > 0 && len(s.Rules[0].Declarations) != tt.decls { | |
| 165 | + | t.Errorf("%q decls=%d diagnostics=%v", tt.src, len(s.Rules[0].Declarations), s.Diagnostics) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
A internal/css/diagnostic_block_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestDeclarationDiagnostics(t *testing.T) { | |
| 9 | + | s := Parse("button {\n unknown: 1;\n color: not-a-color;\n padding: 1px bogus;\n color: var(--later);\n color: blue;\n}") | |
| 10 | + | if len(s.Diagnostics) != 3 { | |
| 11 | + | t.Fatalf("diagnostics: %+v", s.Diagnostics) | |
| 12 | + | } | |
| 13 | + | for i, text := range []string{"unknown property", "invalid value", "invalid value"} { | |
| 14 | + | d := s.Diagnostics[i] | |
| 15 | + | if d.Line != i+2 || d.Col != 3 || !strings.Contains(d.Message, text) { | |
| 16 | + | t.Fatalf("diagnostic %d: %+v", i, d) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | if c := Compile(Sheet{}, s).Compute(&Element{Type: "button"}, nil).Style.Color; c != namedColors["blue"] { | |
| 20 | + | t.Fatalf("color: %+v", c) | |
| 21 | + | } | |
| 22 | + | _, ds := ParseInline("unknown: 1; color: bogus; color: var(--later)") | |
| 23 | + | if len(ds) != 2 || ds[0].Col != 1 || ds[1].Col != 13 { | |
| 24 | + | t.Fatalf("inline: %+v", ds) | |
| 25 | + | } | |
| 26 | + | } | |
A internal/css/engine_test.go +432 -0
| @@ -0,0 +1,432 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "reflect" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestMemoIdentityAndEviction(t *testing.T) { | |
| 11 | + | en := Compile(UA(), Parse(`.on button {color:red} .on:hover > button {background: blue} #x {opacity:.5} button {color:green}`)) | |
| 12 | + | root := Element{Type: "app", Classes: []string{"on"}} | |
| 13 | + | child := Element{Type: "button"} | |
| 14 | + | a := en.Compute(&root, nil) | |
| 15 | + | first := en.Compute(&child, a) | |
| 16 | + | en.EndFrame() | |
| 17 | + | if en.Compute(&root, nil) != a || en.Compute(&child, a) != first { | |
| 18 | + | t.Fatal("memo miss") | |
| 19 | + | } | |
| 20 | + | root.State = Hover | |
| 21 | + | b := en.Compute(&root, nil) | |
| 22 | + | second := en.Compute(&child, b) | |
| 23 | + | if second == first || second.Style.BackgroundColor != namedColors["blue"] { | |
| 24 | + | t.Fatal("state invalidation", second.Style) | |
| 25 | + | } | |
| 26 | + | root.State = 0 | |
| 27 | + | root.Classes = []string{"off"} | |
| 28 | + | c := en.Compute(&root, nil) | |
| 29 | + | third := en.Compute(&child, c) | |
| 30 | + | if third == first || third.Style.Color != namedColors["green"] { | |
| 31 | + | t.Fatal("class invalidation") | |
| 32 | + | } | |
| 33 | + | inline1, ds := ParseInline("color: red; opacity: .2") | |
| 34 | + | if len(ds) > 0 { | |
| 35 | + | t.Fatal(ds) | |
| 36 | + | } | |
| 37 | + | inline2, _ := ParseInline("color: blue; opacity: .2") | |
| 38 | + | child.Inline = inline1 | |
| 39 | + | fourth := en.Compute(&child, a) | |
| 40 | + | child.Inline = inline2 | |
| 41 | + | fifth := en.Compute(&child, a) | |
| 42 | + | if fourth == fifth || fourth.Style.Color != namedColors["red"] || fifth.Style.Color != namedColors["blue"] { | |
| 43 | + | t.Fatal("inline pointer invalidation") | |
| 44 | + | } | |
| 45 | + | for i := 0; i < 4; i++ { | |
| 46 | + | en.EndFrame() | |
| 47 | + | } | |
| 48 | + | if len(en.memo) != 0 { | |
| 49 | + | t.Fatalf("retained %d stale nodes", len(en.memo)) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | func TestCascadeVariablesAndSpecificity(t *testing.T) { | |
| 53 | + | tests := []struct { | |
| 54 | + | css, inline string | |
| 55 | + | want Color | |
| 56 | + | padding float64 | |
| 57 | + | }{ | |
| 58 | + | {`.x {color:red} .x {color:blue}`, "", namedColors["blue"], 0}, | |
| 59 | + | {`.x {color:red!important} #id {color:blue}`, "", namedColors["red"], 0}, | |
| 60 | + | {`.x {color:red!important}`, "color: blue", namedColors["red"], 0}, | |
| 61 | + | {`.x {color:red!important}`, "color: blue!important", namedColors["blue"], 0}, | |
| 62 | + | {`.x {--a: 3px; padding: var(--a); color:var(--missing,red)}`, "", namedColors["red"], 3}, | |
| 63 | + | {`.x {color:green;color:var(--missing)}`, "", Color{A: 1}, 0}, | |
| 64 | + | } | |
| 65 | + | for _, tt := range tests { | |
| 66 | + | en := Compile(UA(), Parse(tt.css)) | |
| 67 | + | d, _ := ParseInline(tt.inline) | |
| 68 | + | s := en.Compute(&Element{Type: "box", ID: "id", Classes: []string{"x"}, Inline: d}, nil).Style | |
| 69 | + | if s.Color != tt.want || s.Padding.Top.Value != tt.padding { | |
| 70 | + | t.Fatalf("%s %s: %v padding %v", tt.css, tt.inline, s.Color, s.Padding.Top) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | func TestClassSetOrder(t *testing.T) { | |
| 75 | + | en := Compile(UA(), Parse(`.a.b {color:red}`)) | |
| 76 | + | a := en.Compute(&Element{Classes: []string{"a", "b"}}, nil) | |
| 77 | + | b := en.Compute(&Element{Classes: []string{"b", "a"}}, nil) | |
| 78 | + | if a != b || a.Style.Color != namedColors["red"] { | |
| 79 | + | t.Fatal("class sets must be order independent") | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func benchmarkTree() ([]Element, []int, Sheet) { | |
| 84 | + | var src strings.Builder | |
| 85 | + | types := []string{"app", "row", "column", "button", "text", "box"} | |
| 86 | + | for i := 0; i < 200; i++ { | |
| 87 | + | switch i % 5 { | |
| 88 | + | case 0: | |
| 89 | + | fmt.Fprintf(&src, "%s {padding:2px; color:#123}\n", types[i%len(types)]) | |
| 90 | + | case 1: | |
| 91 | + | fmt.Fprintf(&src, ".c%d > button:hover {color:red}\n", i%12) | |
| 92 | + | case 2: | |
| 93 | + | fmt.Fprintf(&src, "#id%d {opacity:.5}\n", i%24) | |
| 94 | + | case 3: | |
| 95 | + | fmt.Fprintf(&src, ".c%d text {font-size:18px}\n", i%12) | |
| 96 | + | case 4: | |
| 97 | + | fmt.Fprintf(&src, ".c%d {margin:1px}\n", i%12) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | nodes := make([]Element, 1000) | |
| 101 | + | parents := make([]int, len(nodes)) | |
| 102 | + | for i := range nodes { | |
| 103 | + | nodes[i].Type = types[i%len(types)] | |
| 104 | + | nodes[i].Classes = []string{fmt.Sprintf("c%d", i%12)} | |
| 105 | + | if i%7 == 0 { | |
| 106 | + | nodes[i].ID = fmt.Sprintf("id%d", i%24) | |
| 107 | + | } | |
| 108 | + | if i%11 == 0 { | |
| 109 | + | nodes[i].State = Hover | |
| 110 | + | } | |
| 111 | + | parents[i] = (i - 1) / 3 | |
| 112 | + | } | |
| 113 | + | return nodes, parents, Parse(src.String()) | |
| 114 | + | } | |
| 115 | + | func computeTree(en *Engine, nodes []Element, parents []int, results []*Computed) { | |
| 116 | + | for i := range nodes { | |
| 117 | + | var parent *Computed | |
| 118 | + | if i > 0 { | |
| 119 | + | parent = results[parents[i]] | |
| 120 | + | } | |
| 121 | + | results[i] = en.Compute(&nodes[i], parent) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | func BenchmarkEngine(b *testing.B) { | |
| 125 | + | nodes, parents, sheet := benchmarkTree() | |
| 126 | + | for _, mode := range []string{"cold", "warm", "state-change"} { | |
| 127 | + | b.Run(mode, func(b *testing.B) { | |
| 128 | + | b.ReportAllocs() | |
| 129 | + | en := Compile(UA(), sheet) | |
| 130 | + | results := make([]*Computed, len(nodes)) | |
| 131 | + | computeTree(en, nodes, parents, results) | |
| 132 | + | en.EndFrame() | |
| 133 | + | b.ResetTimer() | |
| 134 | + | for i := 0; i < b.N; i++ { | |
| 135 | + | if mode == "cold" { | |
| 136 | + | en = Compile(UA(), sheet) | |
| 137 | + | } | |
| 138 | + | if mode == "state-change" { | |
| 139 | + | nodes[10].State ^= Hover | |
| 140 | + | } | |
| 141 | + | computeTree(en, nodes, parents, results) | |
| 142 | + | en.EndFrame() | |
| 143 | + | } | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestTypedPropertiesAndVariables(t *testing.T) { | |
| 149 | + | en := Compile(UA(), Parse(`app {color:#123;font-size:20px; --pair: 1em 2px; --ink: rebeccapurple} app > button {padding:var(--pair);border:2px solid var(--ink);font-family:"A B", sans-serif;box-shadow:1px 2px 3px red;line-height:1.5} button:focus-visible {opacity:.4}`)) | |
| 150 | + | root := en.Compute(&Element{Type: "app"}, nil) | |
| 151 | + | c := en.Compute(&Element{Type: "button", State: FocusVisible}, root).Style | |
| 152 | + | if c.Padding.Top.Value != 20 || c.Padding.Right.Value != 2 || c.BorderWidth.Left.Value != 2 || c.BorderColor.Left != namedColors["rebeccapurple"] || c.BorderStyle.Left != KeywordSolid || c.Opacity != .4 || len(c.FontFamily) != 2 || c.FontFamily[0] != "A B" || len(c.BoxShadow) != 1 || c.LineHeightKind != KeywordNumber || c.LineHeightNumber != 1.5 { | |
| 153 | + | t.Fatalf("typed style: %+v", c) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | func TestDuplicateSelectorListUsesHighestMatchingSpecificity(t *testing.T) { | |
| 157 | + | en := Compile(Sheet{}, Parse(`button, #x {color:red} button {color:blue}`)) | |
| 158 | + | if got := en.Compute(&Element{Type: "button", ID: "x"}, nil).Style.Color; got != namedColors["red"] { | |
| 159 | + | t.Fatal(got) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | func TestEndFrameKeepsAncestorsOfLiveDescendant(t *testing.T) { | |
| 163 | + | en := Compile(UA(), Parse(`.a button {color:red}`)) | |
| 164 | + | p := en.Compute(&Element{Type: "app", Classes: []string{"a"}}, nil) | |
| 165 | + | c := en.Compute(&Element{Type: "button"}, p) | |
| 166 | + | for i := 0; i < 5; i++ { | |
| 167 | + | en.Compute(&Element{Type: "button"}, p) | |
| 168 | + | en.EndFrame() | |
| 169 | + | } | |
| 170 | + | if en.memo[p.node.key] != p || en.Compute(&Element{Type: "button"}, p) != c { | |
| 171 | + | t.Fatal("lost live ancestor") | |
| 172 | + | } | |
| 173 | + | for i := 0; i < 3; i++ { | |
| 174 | + | en.EndFrame() | |
| 175 | + | } | |
| 176 | + | if len(en.memo) != 0 { | |
| 177 | + | t.Fatal("eviction leak", len(en.memo)) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestEvictionBoundAcrossChangingStates(t *testing.T) { | |
| 182 | + | en := Compile(UA(), Parse(`.a:hover button{color:red}`)) | |
| 183 | + | root := Element{Type: "app", Classes: []string{"a"}} | |
| 184 | + | child := Element{Type: "button"} | |
| 185 | + | for frame := 0; frame < 100; frame++ { | |
| 186 | + | root.State = StateFlags(frame%2) * Hover | |
| 187 | + | p := en.Compute(&root, nil) | |
| 188 | + | en.Compute(&child, p) | |
| 189 | + | en.EndFrame() | |
| 190 | + | if len(en.memo) > 4 { | |
| 191 | + | t.Fatalf("frame %d: %d nodes", frame, len(en.memo)) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestVariableCSSWideAndInvalidShorthand(t *testing.T) { | |
| 197 | + | en := Compile(Sheet{}, Parse(`app{color:blue;font-size:22px} button{--s:inherit;color:var(--s);--x:bad; margin:7px;margin:var(--x); border:1px solid red;border:var(--x)}`)) | |
| 198 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 199 | + | s := en.Compute(&Element{Type: "button"}, p).Style | |
| 200 | + | if s.Color != p.Style.Color || s.Margin.Top.Value != 0 || s.BorderWidth.Top.Value != 0 { | |
| 201 | + | t.Fatalf("variable cascade: %+v", s) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestInlineInvalidAndSheetSnapshot(t *testing.T) { | |
| 206 | + | sheet := Parse(`button{color:red}`) | |
| 207 | + | en := Compile(Sheet{}, sheet) | |
| 208 | + | sheet.Rules[0].Declarations[0].Value = "blue" | |
| 209 | + | d, _ := ParseInline("color: nonsense; opacity:.2") | |
| 210 | + | got := en.Compute(&Element{Type: "button", Inline: d}, nil).Style | |
| 211 | + | if got.Color != namedColors["red"] || got.Opacity != .2 { | |
| 212 | + | t.Fatalf("invalid inline or sheet mutation: %+v", got) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestStylePropertyCoverage(t *testing.T) { | |
| 217 | + | if len(propertyNames) != int(propertyCount) { | |
| 218 | + | t.Fatal("property enum and table diverged") | |
| 219 | + | } | |
| 220 | + | for id, name := range propertyNames { | |
| 221 | + | if got, ok := propertyIndex(name); !ok || int(got) != id { | |
| 222 | + | t.Fatalf("property %q: %d %v", name, got, ok) | |
| 223 | + | } | |
| 224 | + | v, ok := parseValue(name, initial(name), Color{A: 1}, 16, 16) | |
| 225 | + | if !ok { | |
| 226 | + | t.Fatalf("invalid default for %q", name) | |
| 227 | + | } | |
| 228 | + | s := &Style{} | |
| 229 | + | s.set(propertyID(id), v) | |
| 230 | + | want := s.value(propertyID(id)) | |
| 231 | + | if name == "line-height" && want.Keyword != "normal" { | |
| 232 | + | t.Fatalf("line-height default: %+v", want) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestKeywordAndCurrentColorInheritance(t *testing.T) { | |
| 238 | + | en := Compile(Sheet{}, Parse(`app{color:blue; font-style:italic; font-size:20px} button{color:currentColor; border-color:currentColor; border-style:solid; font-style:oblique; font-size:1em; line-height:2; letter-spacing:normal}`)) | |
| 239 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 240 | + | s := en.Compute(&Element{Type: "button"}, p).Style | |
| 241 | + | if s.Color != p.Style.Color || s.BorderColor.Top != s.Color || s.BorderStyle.Top != KeywordSolid || s.FontStyle != KeywordOblique || s.FontSize.Value != 20 || s.LineHeightKind != KeywordNumber || s.LineHeightNumber != 2 || !s.LetterSpacingNormal { | |
| 242 | + | t.Fatalf("keyword/color style %+v", s) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestVariableInvalidatesWholeWinningShorthand(t *testing.T) { | |
| 247 | + | en := Compile(Sheet{}, Parse(`button{--p:5px nonsense;padding:3px;padding:var(--p)}`)) | |
| 248 | + | s := en.Compute(&Element{Type: "button"}, nil).Style | |
| 249 | + | if s.Padding.Top.Value != 0 || s.Padding.Right.Value != 0 { | |
| 250 | + | t.Fatal("invalid shorthand did not reset", s.Padding) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestInvalidStaticShorthandIgnored(t *testing.T) { | |
| 255 | + | en := Compile(Sheet{}, Parse(`button{padding:3px;padding:5px nonsense;border:2px solid red;border:garbage green}`)) | |
| 256 | + | s := en.Compute(&Element{Type: "button"}, nil).Style | |
| 257 | + | if s.Padding.Top.Value != 3 || s.BorderWidth.Top.Value != 2 || s.BorderColor.Top != namedColors["red"] { | |
| 258 | + | t.Fatalf("partial invalid shorthand: %+v", s) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestCSSWideShorthand(t *testing.T) { | |
| 263 | + | en := Compile(Sheet{}, Parse(`app{color:blue} button{padding:5px; padding:initial; border:2px solid red; border:unset; color:red; color:inherit}`)) | |
| 264 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 265 | + | s := en.Compute(&Element{Type: "button"}, p).Style | |
| 266 | + | if s.Padding.Top.Value != 0 || s.BorderWidth.Top.Value != 0 || s.Color != p.Style.Color { | |
| 267 | + | t.Fatalf("wide shorthand: %+v", s) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestCSSWideVariableShorthand(t *testing.T) { | |
| 272 | + | en := Compile(Sheet{}, Parse(`button{--x:initial; padding:9px; padding:var(--x); border:2px solid red;border:var(--x)}`)) | |
| 273 | + | s := en.Compute(&Element{Type: "button"}, nil).Style | |
| 274 | + | if s.Padding.Top.Value != 0 || s.BorderWidth.Top.Value != 0 || s.BorderStyle.Top != KeywordNone { | |
| 275 | + | t.Fatalf("wide var shorthand: %+v", s) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func TestUnusedMemoEvictedAfterTwoFrames(t *testing.T) { | |
| 280 | + | en := Compile(Sheet{}, Sheet{}) | |
| 281 | + | en.Compute(&Element{Type: "app"}, nil) | |
| 282 | + | en.EndFrame() | |
| 283 | + | if len(en.memo) != 1 { | |
| 284 | + | t.Fatal("evicted too early") | |
| 285 | + | } | |
| 286 | + | en.EndFrame() | |
| 287 | + | if len(en.memo) != 0 { | |
| 288 | + | t.Fatal("evicted too late") | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestStyleInitialMatchesPropertyDefaults(t *testing.T) { | |
| 293 | + | s := Compile(Sheet{}, Sheet{}).Compute(&Element{Type: "app"}, nil).Style | |
| 294 | + | for id, name := range propertyNames { | |
| 295 | + | want, ok := parseValue(name, initial(name), s.Color, 16, 16) | |
| 296 | + | if !ok { | |
| 297 | + | t.Fatalf("invalid initial %s", name) | |
| 298 | + | } | |
| 299 | + | got := s.value(propertyID(id)) | |
| 300 | + | // Slices are shared immutable values and not directly comparable. | |
| 301 | + | switch name { | |
| 302 | + | case "font-family": | |
| 303 | + | if !reflect.DeepEqual(got.Families, want.Families) { | |
| 304 | + | t.Fatalf("%s: %+v vs %+v", name, got, want) | |
| 305 | + | } | |
| 306 | + | case "box-shadow": | |
| 307 | + | if len(got.Shadows) != len(want.Shadows) { | |
| 308 | + | t.Fatalf("%s: %+v vs %+v", name, got, want) | |
| 309 | + | } | |
| 310 | + | default: | |
| 311 | + | if got.Length != want.Length || got.Color != want.Color || got.Number != want.Number || got.Keyword != want.Keyword { | |
| 312 | + | t.Fatalf("%s: %+v vs %+v", name, got, want) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestInheritedFieldsAndCurrentColorDefaults(t *testing.T) { | |
| 319 | + | en := Compile(Sheet{}, Parse(`app{color:red; font-family:serif; font-size:22px; font-style:italic; cursor:pointer; text-align:center;line-height:2;letter-spacing:1px;accent-color:green} button{background-color:blue}`)) | |
| 320 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 321 | + | c := en.Compute(&Element{Type: "button"}, p).Style | |
| 322 | + | if c.Color != p.Style.Color || c.AccentColor != namedColors["green"] || c.BorderColor.Top != c.Color || c.OutlineColor != c.Color || c.FontSize != p.Style.FontSize || c.FontStyle != KeywordItalic || c.FontFamily[0] != "serif" || c.Cursor != KeywordPointer || c.TextAlign != KeywordCenter || c.LineHeightNumber != 2 || c.LetterSpacing.Value != 1 || c.BackgroundColor != namedColors["blue"] { | |
| 323 | + | t.Fatalf("inheritance: %+v", c) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestComputedInitialInheritsColorButResetsBackground(t *testing.T) { | |
| 328 | + | en := Compile(Sheet{}, Parse(`app{color:red;background:blue}button{background:initial}`)) | |
| 329 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 330 | + | c := en.Compute(&Element{Type: "button"}, p).Style | |
| 331 | + | if c.Color != namedColors["red"] || c.BackgroundColor != (Color{}) || c.BorderColor.Top != c.Color { | |
| 332 | + | t.Fatalf("computed initial: %+v", c) | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func TestChangedStateMissesOnlySubtree(t *testing.T) { | |
| 337 | + | nodes, parents, sheet := benchmarkTree() | |
| 338 | + | en := Compile(UA(), sheet) | |
| 339 | + | results := make([]*Computed, len(nodes)) | |
| 340 | + | computeTree(en, nodes, parents, results) | |
| 341 | + | before := append([]*Computed(nil), results...) | |
| 342 | + | en.EndFrame() | |
| 343 | + | const changed = 10 | |
| 344 | + | nodes[changed].State ^= Hover | |
| 345 | + | computeTree(en, nodes, parents, results) | |
| 346 | + | descendants := make([]bool, len(nodes)) | |
| 347 | + | descendants[changed] = true | |
| 348 | + | expected, misses := 0, 0 | |
| 349 | + | for i := range nodes { | |
| 350 | + | if i > changed && descendants[parents[i]] { | |
| 351 | + | descendants[i] = true | |
| 352 | + | } | |
| 353 | + | if descendants[i] { | |
| 354 | + | expected++ | |
| 355 | + | } | |
| 356 | + | if results[i] != before[i] { | |
| 357 | + | misses++ | |
| 358 | + | if !descendants[i] { | |
| 359 | + | t.Fatalf("unrelated node %d recomputed", i) | |
| 360 | + | } | |
| 361 | + | } else if descendants[i] { | |
| 362 | + | t.Fatalf("descendant %d reused stale result", i) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | if misses != expected { | |
| 366 | + | t.Fatalf("Compute misses %d; affected subtree size %d", misses, expected) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func TestPendingVariableShorthandsAtomic(t *testing.T) { | |
| 371 | + | cases := []struct { | |
| 372 | + | property, base, valid, invalid string | |
| 373 | + | targets []string | |
| 374 | + | }{ | |
| 375 | + | {"background", "blue", "red", "no-color", []string{"background-color"}}, | |
| 376 | + | {"margin", "9px", "1px 2px 3px", "1px nope", shorthandTargets("margin")}, | |
| 377 | + | {"padding", "9px", "1px 2px 3px", "1px nope", shorthandTargets("padding")}, | |
| 378 | + | {"border-width", "9px", "1px 2px 3px", "1px nope", shorthandTargets("border-width")}, | |
| 379 | + | {"border-style", "none", "solid", "solid dotted", shorthandTargets("border-style")}, | |
| 380 | + | {"border-color", "blue", "red green", "red nope", shorthandTargets("border-color")}, | |
| 381 | + | {"border-radius", "9px", "1px 2px 3px", "1px nope", shorthandTargets("border-radius")}, | |
| 382 | + | {"border-top", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-top")}, | |
| 383 | + | {"border-right", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-right")}, | |
| 384 | + | {"border-bottom", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-bottom")}, | |
| 385 | + | {"border-left", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border-left")}, | |
| 386 | + | {"border", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("border")}, | |
| 387 | + | {"outline", "2px solid blue", "3px solid red", "3px dotted red", shorthandTargets("outline")}, | |
| 388 | + | {"flex", "none", "2 3 10px", "2 garbage", shorthandTargets("flex")}, | |
| 389 | + | {"gap", "9px", "1px 2px", "1px nope", shorthandTargets("gap")}, | |
| 390 | + | {"overflow", "hidden", "scroll auto", "scroll garbage", shorthandTargets("overflow")}, | |
| 391 | + | } | |
| 392 | + | baseline := Compile(Sheet{}, Sheet{}).Compute(&Element{Type: "button"}, nil).Style | |
| 393 | + | for _, tt := range cases { | |
| 394 | + | t.Run(tt.property, func(t *testing.T) { | |
| 395 | + | css := func(v string) *Style { | |
| 396 | + | src := "button{--x:" + v + ";" + tt.property + ":" + tt.base + ";" + tt.property + ":var(--x)}" | |
| 397 | + | return Compile(Sheet{}, Parse(src)).Compute(&Element{Type: "button"}, nil).Style | |
| 398 | + | } | |
| 399 | + | good, bad := css(tt.valid), css(tt.invalid) | |
| 400 | + | for _, name := range tt.targets { | |
| 401 | + | id, ok := propertyIndex(name) | |
| 402 | + | if !ok { | |
| 403 | + | t.Fatalf("unknown target %s", name) | |
| 404 | + | } | |
| 405 | + | if reflect.DeepEqual(good.value(id), baseline.value(id)) { | |
| 406 | + | t.Errorf("valid %s did not override %s", tt.valid, name) | |
| 407 | + | } | |
| 408 | + | if !reflect.DeepEqual(bad.value(id), baseline.value(id)) { | |
| 409 | + | t.Errorf("invalid %s did not reset %s: %+v", tt.invalid, name, bad.value(id)) | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestPendingShorthandWinnerPerLonghand(t *testing.T) { | |
| 417 | + | en := Compile(Sheet{}, Parse(`button{--pair:1px 2px; padding:var(--pair); padding-left:7px; padding-right:9px!important}`)) | |
| 418 | + | s := en.Compute(&Element{Type: "button"}, nil).Style | |
| 419 | + | if s.Padding.Top.Value != 1 || s.Padding.Bottom.Value != 1 || s.Padding.Left.Value != 7 || s.Padding.Right.Value != 9 { | |
| 420 | + | t.Fatal(s.Padding) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func TestPendingShorthandInheritedCustomAndInline(t *testing.T) { | |
| 425 | + | en := Compile(Sheet{}, Parse(`app{--space:1em 2px;color:red}button{padding:var(--space);margin:9px;margin:var(--missing);border:1px solid blue;border:var(--missing)}`)) | |
| 426 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 427 | + | in, _ := ParseInline(`padding:var(--space);border:var(--missing, 2px solid currentColor)`) | |
| 428 | + | c := en.Compute(&Element{Type: "button", Inline: in}, p).Style | |
| 429 | + | if c.Padding.Top.Value != 16 || c.Padding.Right.Value != 2 || c.Margin.Top.Value != 0 || c.BorderWidth.Left.Value != 2 || c.BorderColor.Left != namedColors["red"] { | |
| 430 | + | t.Fatalf("pending group inheritance/inline %+v", c) | |
| 431 | + | } | |
| 432 | + | } | |
A internal/css/grammar_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestDocumentedGrammar(t *testing.T) { | |
| 6 | + | selectors := []struct { | |
| 7 | + | selector string | |
| 8 | + | match bool | |
| 9 | + | }{ | |
| 10 | + | {"button", true}, {"BUTTON", true}, {".ok", true}, {"#x", true}, | |
| 11 | + | {"button.ok#x:hover", true}, {"app button", true}, {"app > button", true}, | |
| 12 | + | {"button, row", true}, {"button:active", false}, {"button:focus", false}, | |
| 13 | + | {"button:focus-visible", false}, {"button:disabled", false}, {"button:checked", false}, | |
| 14 | + | {"app > row > button", false}, {"row button", false}, | |
| 15 | + | } | |
| 16 | + | for _, tt := range selectors { | |
| 17 | + | t.Run(tt.selector, func(t *testing.T) { | |
| 18 | + | en := Compile(Sheet{}, Parse(tt.selector+" {color:red}")) | |
| 19 | + | p := en.Compute(&Element{Type: "app"}, nil) | |
| 20 | + | c := en.Compute(&Element{Type: "button", ID: "x", Classes: []string{"ok"}, State: Hover}, p).Style.Color == namedColors["red"] | |
| 21 | + | if c != tt.match { | |
| 22 | + | t.Fatalf("match=%v expected %v", c, tt.match) | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | bad := []string{"*", "[type=button]", "button+row", "button~row", "button:not(.x)", "button::before", "button:future", "button, :future"} | |
| 27 | + | for _, s := range bad { | |
| 28 | + | t.Run("invalid-"+s, func(t *testing.T) { | |
| 29 | + | parsed := Parse(s + "{color:red} button{color:blue}") | |
| 30 | + | if len(parsed.Rules) != 1 || len(parsed.Diagnostics) == 0 || Compile(Sheet{}, parsed).Compute(&Element{Type: "button"}, nil).Style.Color != namedColors["blue"] { | |
| 31 | + | t.Fatalf("did not drop whole bad rule: %+v", parsed) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | t.Run("unclosed EOF", func(t *testing.T) { | |
| 36 | + | parsed := Parse(`button{color:red`) | |
| 37 | + | if len(parsed.Rules) != 0 || len(parsed.Diagnostics) == 0 { | |
| 38 | + | t.Fatal(parsed) | |
| 39 | + | } | |
| 40 | + | }) | |
| 41 | + | cases := []struct { | |
| 42 | + | src string | |
| 43 | + | want string | |
| 44 | + | }{ | |
| 45 | + | {`button{bad: rgb(1;2;3); color:red}`, "red"}, | |
| 46 | + | {`button{bad: [1;2;3]; color:blue}`, "blue"}, | |
| 47 | + | {`button{bad:{one;two}; color:green}`, "green"}, | |
| 48 | + | {`button{no-colon; color:red}`, "red"}, | |
| 49 | + | } | |
| 50 | + | for _, tt := range cases { | |
| 51 | + | t.Run(tt.src, func(t *testing.T) { | |
| 52 | + | p := Parse(tt.src) | |
| 53 | + | if len(p.Rules) != 1 { | |
| 54 | + | t.Fatal(p) | |
| 55 | + | } | |
| 56 | + | want := namedColors[tt.want] | |
| 57 | + | if c := Compile(Sheet{}, p).Compute(&Element{Type: "button"}, nil).Style.Color; c != want { | |
| 58 | + | t.Fatalf("got %+v, want %+v; parse %+v", c, want, p) | |
| 59 | + | } | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestDocumentedValueGrammar(t *testing.T) { | |
| 65 | + | colors := []struct{ valid, invalid string }{ | |
| 66 | + | {"red", "not-a-color"}, {"transparent", "rgb(nan,0,0)"}, {"currentColor", "#xx0"}, | |
| 67 | + | {"#abc", "#12"}, {"#abcd", "#12345"}, {"#112233", "#1234567"}, {"#11223344", "#123456789"}, | |
| 68 | + | {"rgb(255,0,0)", "rgb(1,2)"}, {"rgba(255 0 0 / 50%)", "rgba(1,2,3)"}, | |
| 69 | + | {"hsl(0 100% 50%)", "hsl(0 50 50%)"}, {"hsla(0,100%,50%,.5)", "hsla(0,100%,50%)"}, | |
| 70 | + | } | |
| 71 | + | for _, tt := range colors { | |
| 72 | + | t.Run(tt.valid, func(t *testing.T) { | |
| 73 | + | if _, ok := parseColor(tt.valid, Color{A: 1}); !ok { | |
| 74 | + | t.Error("valid color rejected") | |
| 75 | + | } | |
| 76 | + | if _, ok := parseColor(tt.invalid, Color{A: 1}); ok { | |
| 77 | + | t.Errorf("invalid color accepted %s", tt.invalid) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | lengths := []struct{ valid, invalid string }{ | |
| 82 | + | {"2px", "2pt"}, {"2%", "2vw"}, {"2em", "2ex"}, {"2rem", "2vh"}, {"0", "2"}, | |
| 83 | + | } | |
| 84 | + | for _, tt := range lengths { | |
| 85 | + | t.Run(tt.valid, func(t *testing.T) { | |
| 86 | + | if _, ok := length(tt.valid, false, false); !ok { | |
| 87 | + | t.Fatal("valid length rejected") | |
| 88 | + | } | |
| 89 | + | if _, ok := length(tt.invalid, false, false); ok { | |
| 90 | + | t.Fatalf("invalid length accepted %s", tt.invalid) | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | if _, ok := length("auto", false, true); !ok { | |
| 95 | + | t.Fatal("auto rejected where allowed") | |
| 96 | + | } | |
| 97 | + | if _, ok := length("auto", false, false); ok { | |
| 98 | + | t.Fatal("auto accepted where disallowed") | |
| 99 | + | } | |
| 100 | + | if _, ok := length("-1px", true, false); !ok { | |
| 101 | + | t.Fatal("signed length rejected") | |
| 102 | + | } | |
| 103 | + | if _, ok := length("-1px", false, false); ok { | |
| 104 | + | t.Fatal("negative length accepted") | |
| 105 | + | } | |
| 106 | + | } | |
A internal/css/intern_block_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestInternEvictionAndSelectorIdentity(t *testing.T) { | |
| 9 | + | en := Compile(Sheet{}, Parse(".keep{color:red} .other{color:blue} app .keep{opacity:.5}")) | |
| 10 | + | root := en.Compute(&Element{Type: "app"}, nil) | |
| 11 | + | for frame := 0; frame < 80; frame++ { | |
| 12 | + | en.Compute(&Element{Type: "button", ID: fmt.Sprint(frame), Classes: []string{fmt.Sprintf("transient-%d", frame)}}, root) | |
| 13 | + | en.EndFrame() | |
| 14 | + | root = en.Compute(&Element{Type: "app"}, nil) | |
| 15 | + | keep := en.Compute(&Element{Type: "button", Classes: []string{"keep"}}, root) | |
| 16 | + | if keep.Style.Color != namedColors["red"] || keep.Style.Opacity != .5 { | |
| 17 | + | t.Fatalf("selector lost at frame %d: %+v", frame, keep.Style) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | en.EndFrame() | |
| 21 | + | en.EndFrame() | |
| 22 | + | if len(en.ids) > 10 || len(en.sets) > 2 || len(en.classMembers) > 2 || len(en.memo) > 3 { | |
| 23 | + | t.Fatalf("tables not bounded: ids=%d sets=%d members=%d memo=%d", len(en.ids), len(en.sets), len(en.classMembers), len(en.memo)) | |
| 24 | + | } | |
| 25 | + | en.Compute(&Element{Type: "button", Classes: []string{"other"}}, nil) | |
| 26 | + | if got := en.Compute(&Element{Type: "button", Classes: []string{"keep"}}, nil).Style.Color; got != namedColors["red"] { | |
| 27 | + | t.Fatalf("reused class matched wrong rule: %+v", got) | |
| 28 | + | } | |
| 29 | + | } | |
A internal/css/parse.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | type Diagnostic struct { | |
| 8 | + | Line, Col int | |
| 9 | + | Message string | |
| 10 | + | } | |
| 11 | + | type Declaration struct { | |
| 12 | + | Property, Value string | |
| 13 | + | Important bool | |
| 14 | + | Line, Col int | |
| 15 | + | } | |
| 16 | + | type Rule struct { | |
| 17 | + | Selectors []Selector | |
| 18 | + | Declarations []Declaration | |
| 19 | + | } | |
| 20 | + | type Sheet struct { | |
| 21 | + | Rules []Rule | |
| 22 | + | Diagnostics []Diagnostic | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func Parse(src string) Sheet { | |
| 26 | + | ts := Tokenize(src) | |
| 27 | + | sheet := Sheet{} | |
| 28 | + | diag := func(t Token, msg string) { | |
| 29 | + | sheet.Diagnostics = append(sheet.Diagnostics, Diagnostic{t.Line, t.Col, msg}) | |
| 30 | + | } | |
| 31 | + | for i := 0; i < len(ts); { | |
| 32 | + | for i < len(ts) && ts[i].Kind == "space" { | |
| 33 | + | i++ | |
| 34 | + | } | |
| 35 | + | if i >= len(ts) { | |
| 36 | + | break | |
| 37 | + | } | |
| 38 | + | start := i | |
| 39 | + | for i < len(ts) && ts[i].Kind != "{" && ts[i].Kind != "}" && ts[i].Kind != ";" { | |
| 40 | + | i++ | |
| 41 | + | } | |
| 42 | + | if i >= len(ts) { | |
| 43 | + | diag(ts[start], "malformed rule") | |
| 44 | + | break | |
| 45 | + | } | |
| 46 | + | if ts[i].Kind != "{" { | |
| 47 | + | diag(ts[start], "malformed rule") | |
| 48 | + | i++ | |
| 49 | + | continue | |
| 50 | + | } | |
| 51 | + | sels, ok := parseSelectors(ts[start:i]) | |
| 52 | + | i++ | |
| 53 | + | depth := 1 | |
| 54 | + | body := i | |
| 55 | + | for i < len(ts) && depth > 0 { | |
| 56 | + | switch ts[i].Kind { | |
| 57 | + | case "{": | |
| 58 | + | depth++ | |
| 59 | + | case "}": | |
| 60 | + | depth-- | |
| 61 | + | } | |
| 62 | + | i++ | |
| 63 | + | } | |
| 64 | + | if depth > 0 { | |
| 65 | + | diag(ts[start], "unclosed rule") | |
| 66 | + | break | |
| 67 | + | } | |
| 68 | + | if !ok { | |
| 69 | + | diag(ts[start], "invalid selector") | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | r := Rule{Selectors: sels} | |
| 73 | + | contents := ts[body : i-1] | |
| 74 | + | for j := 0; j < len(contents); { | |
| 75 | + | for j < len(contents) && (contents[j].Kind == "space" || contents[j].Kind == ";") { | |
| 76 | + | j++ | |
| 77 | + | } | |
| 78 | + | if j >= len(contents) { | |
| 79 | + | break | |
| 80 | + | } | |
| 81 | + | b := j | |
| 82 | + | // Consume component values: delimiters inside functions or blocks are not | |
| 83 | + | // declaration terminators. A stray block is an invalid declaration. | |
| 84 | + | depthParen, depthBracket, depthBrace := 0, 0, 0 | |
| 85 | + | malformedBlock := false | |
| 86 | + | for j < len(contents) { | |
| 87 | + | switch contents[j].Kind { | |
| 88 | + | case "function", "(": | |
| 89 | + | depthParen++ | |
| 90 | + | case ")": | |
| 91 | + | if depthParen > 0 { | |
| 92 | + | depthParen-- | |
| 93 | + | } | |
| 94 | + | case "[": | |
| 95 | + | depthBracket++ | |
| 96 | + | case "]": | |
| 97 | + | if depthBracket > 0 { | |
| 98 | + | depthBracket-- | |
| 99 | + | } | |
| 100 | + | case "{": | |
| 101 | + | depthBrace++ | |
| 102 | + | malformedBlock = true | |
| 103 | + | case "}": | |
| 104 | + | if depthBrace > 0 { | |
| 105 | + | depthBrace-- | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if contents[j].Kind == ";" && depthParen == 0 && depthBracket == 0 && depthBrace == 0 { | |
| 109 | + | break | |
| 110 | + | } | |
| 111 | + | j++ | |
| 112 | + | } | |
| 113 | + | end := j | |
| 114 | + | if malformedBlock { | |
| 115 | + | diag(contents[b], "malformed declaration") | |
| 116 | + | if j < len(contents) { | |
| 117 | + | j++ | |
| 118 | + | } | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | if j < len(contents) { | |
| 122 | + | j++ | |
| 123 | + | } | |
| 124 | + | colon := -1 | |
| 125 | + | for k := b; k < end; k++ { | |
| 126 | + | if contents[k].Kind == ":" { | |
| 127 | + | colon = k | |
| 128 | + | break | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if colon < 0 { | |
| 132 | + | diag(contents[b], "malformed declaration") | |
| 133 | + | continue | |
| 134 | + | } | |
| 135 | + | name := strings.TrimSpace(raw(contents[b:colon])) | |
| 136 | + | value := strings.TrimSpace(raw(contents[colon+1 : end])) | |
| 137 | + | if name == "" || value == "" && !strings.HasPrefix(name, "--") { | |
| 138 | + | diag(contents[b], "malformed declaration") | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | important := false | |
| 142 | + | v := Tokenize(value) | |
| 143 | + | e := len(v) - 1 | |
| 144 | + | for e >= 0 && v[e].Kind == "space" { | |
| 145 | + | e-- | |
| 146 | + | } | |
| 147 | + | if e >= 0 && strings.EqualFold(v[e].Value, "important") { | |
| 148 | + | k := e - 1 | |
| 149 | + | for k >= 0 && v[k].Kind == "space" { | |
| 150 | + | k-- | |
| 151 | + | } | |
| 152 | + | if k >= 0 && v[k].Kind == "!" { | |
| 153 | + | important = true | |
| 154 | + | value = strings.TrimSpace(raw(v[:k])) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if !strings.HasPrefix(name, "--") { | |
| 158 | + | name = strings.ToLower(name) | |
| 159 | + | } | |
| 160 | + | d := Declaration{name, value, important, contents[b].Line, contents[b].Col} | |
| 161 | + | if len(compileDecls([]Declaration{d})) == 0 { | |
| 162 | + | msg := "invalid value for " + name | |
| 163 | + | if !strings.HasPrefix(name, "--") && len(shorthandTargets(name)) == 0 { | |
| 164 | + | if _, known := propertyIndex(name); !known { | |
| 165 | + | msg = "unknown property: " + name | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | diag(contents[b], msg) | |
| 169 | + | } | |
| 170 | + | r.Declarations = append(r.Declarations, d) | |
| 171 | + | } | |
| 172 | + | sheet.Rules = append(sheet.Rules, r) | |
| 173 | + | } | |
| 174 | + | return sheet | |
| 175 | + | } | |
| 176 | + | func raw(ts []Token) string { | |
| 177 | + | var b strings.Builder | |
| 178 | + | for _, t := range ts { | |
| 179 | + | b.WriteString(t.Raw) | |
| 180 | + | } | |
| 181 | + | return b.String() | |
| 182 | + | } | |
A internal/css/property_table_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Every row in docs/css.md has at least one accepted and one rejected example. | |
| 9 | + | // A valid example must override the row's initial; an invalid example must not. | |
| 10 | + | func TestDocumentedPropertyRows(t *testing.T) { | |
| 11 | + | rows := []struct{ name, valid, invalid string }{ | |
| 12 | + | {"color", "red", "rgb(nope)"}, {"opacity", ".5", "2"}, | |
| 13 | + | {"background-color", "#abc", "#xyq"}, {"background", "red", "garbage"}, | |
| 14 | + | {"border-width", "1px 2px 3px 4px", "1px 2px 3px 4px 5px"}, | |
| 15 | + | {"border-style", "solid none", "dotted"}, {"border-color", "red blue", "invalid"}, | |
| 16 | + | {"border", "2px solid red", "2px dashed red"}, | |
| 17 | + | {"border-radius", "1px 2px 3px 4px", "2px / 1px"}, | |
| 18 | + | {"outline", "1px solid red", "1px dotted"}, | |
| 19 | + | {"outline-offset", "-2px", "1%"}, | |
| 20 | + | {"box-shadow", "inset 1px 2px 3px -4px red, 0 0 blue", "1px 2px -3px red"}, | |
| 21 | + | {"margin", "auto -1px 2% 3rem", "1px 2px 3px 4px 5px"}, | |
| 22 | + | {"padding", "1px 2px 3px 4px", "-1px"}, | |
| 23 | + | {"box-sizing", "border-box", "padding-box"}, | |
| 24 | + | {"display", "flex", "grid"}, | |
| 25 | + | {"flex-direction", "column", "diagonal"}, | |
| 26 | + | {"flex-wrap", "wrap", "reverse"}, | |
| 27 | + | {"flex", "2 3 10px", "2 nope"}, | |
| 28 | + | {"gap", "1px 2px", "1px 2px 3px"}, | |
| 29 | + | {"justify-content", "space-evenly", "between"}, | |
| 30 | + | {"overflow", "hidden auto", "clip"}, | |
| 31 | + | {"font-family", `"A B", serif`, "serif,,monospace"}, | |
| 32 | + | {"font-size", "120%", "-1px"}, | |
| 33 | + | {"font-weight", "bold", "550"}, | |
| 34 | + | {"font-style", "italic", "solid"}, | |
| 35 | + | {"line-height", "1.5", "-1px"}, | |
| 36 | + | {"text-align", "center", "justify"}, | |
| 37 | + | {"letter-spacing", "-1px", "1%"}, | |
| 38 | + | {"cursor", "pointer", "grab"}, | |
| 39 | + | {"accent-color", "red", "notacolor"}, | |
| 40 | + | {"--accent", "red", "var(--missing)"}, | |
| 41 | + | } | |
| 42 | + | for _, side := range sideNames { | |
| 43 | + | rows = append(rows, | |
| 44 | + | struct{ name, valid, invalid string }{"border-" + side + "-width", "2px", "-1px"}, | |
| 45 | + | struct{ name, valid, invalid string }{"border-" + side + "-style", "solid", "dotted"}, | |
| 46 | + | struct{ name, valid, invalid string }{"border-" + side + "-color", "red", "notacolor"}, | |
| 47 | + | struct{ name, valid, invalid string }{"border-" + side, "2px solid red", "2px dashed red"}, | |
| 48 | + | struct{ name, valid, invalid string }{"margin-" + side, "-1em", "banana"}, | |
| 49 | + | struct{ name, valid, invalid string }{"padding-" + side, "2px", "-1px"}, | |
| 50 | + | ) | |
| 51 | + | } | |
| 52 | + | for _, corner := range cornerNames { | |
| 53 | + | rows = append(rows, struct{ name, valid, invalid string }{"border-" + corner + "-radius", "2%", "-1px"}) | |
| 54 | + | } | |
| 55 | + | for _, name := range []string{"width", "height", "min-width", "min-height", "max-width", "max-height", "flex-basis"} { | |
| 56 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "2rem", "-1px"}) | |
| 57 | + | } | |
| 58 | + | for _, name := range []string{"flex-grow", "flex-shrink"} { | |
| 59 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "2.5", "-1"}) | |
| 60 | + | } | |
| 61 | + | for _, name := range []string{"row-gap", "column-gap"} { | |
| 62 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "2px", "-2px"}) | |
| 63 | + | } | |
| 64 | + | for _, name := range []string{"align-items", "align-self"} { | |
| 65 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "center", "baseline"}) | |
| 66 | + | } | |
| 67 | + | for _, name := range []string{"overflow-x", "overflow-y"} { | |
| 68 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "scroll", "clip"}) | |
| 69 | + | } | |
| 70 | + | for _, name := range []string{"outline-width"} { | |
| 71 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "1px", "-1px"}) | |
| 72 | + | } | |
| 73 | + | for _, name := range []string{"outline-style"} { | |
| 74 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "solid", "dashed"}) | |
| 75 | + | } | |
| 76 | + | for _, name := range []string{"outline-color"} { | |
| 77 | + | rows = append(rows, struct{ name, valid, invalid string }{name, "red", "nonsense"}) | |
| 78 | + | } | |
| 79 | + | for _, r := range rows { | |
| 80 | + | t.Run(r.name, func(t *testing.T) { | |
| 81 | + | if r.name == "--accent" { | |
| 82 | + | en := Compile(Sheet{}, Parse(`button{--accent:red;color:var(--accent)}`)) | |
| 83 | + | if en.Compute(&Element{Type: "button"}, nil).Style.Color != namedColors["red"] { | |
| 84 | + | t.Fatal("custom property not resolved") | |
| 85 | + | } | |
| 86 | + | bad := Compile(Sheet{}, Parse(`button{--accent:var(--missing);color:var(--accent)}`)) | |
| 87 | + | if bad.Compute(&Element{Type: "button"}, nil).Style.Color != (Color{A: 1}) { | |
| 88 | + | t.Fatal("invalid custom property not handled") | |
| 89 | + | } | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | initial := Compile(Sheet{}, Sheet{}).Compute(&Element{Type: "button"}, nil).Style | |
| 93 | + | valid := Compile(Sheet{}, Parse("button{"+r.name+":"+r.valid+"}")).Compute(&Element{Type: "button"}, nil).Style | |
| 94 | + | invalid := Compile(Sheet{}, Parse("button{"+r.name+":"+r.invalid+"}")).Compute(&Element{Type: "button"}, nil).Style | |
| 95 | + | if reflect.DeepEqual(*valid, *initial) { | |
| 96 | + | t.Errorf("valid value %q did not alter style", r.valid) | |
| 97 | + | } | |
| 98 | + | if !reflect.DeepEqual(*invalid, *initial) { | |
| 99 | + | t.Errorf("invalid value %q changed style", r.invalid) | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
A internal/css/selector.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // StateFlags are the supported CSS pseudo-class states. | |
| 6 | + | type StateFlags uint8 | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | Hover StateFlags = 1 << iota | |
| 10 | + | Active | |
| 11 | + | Focus | |
| 12 | + | FocusVisible | |
| 13 | + | Disabled | |
| 14 | + | Checked | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Element is the matching identity for one Compute call. Parentage comes from Computed. | |
| 18 | + | type Element struct { | |
| 19 | + | Type, ID string | |
| 20 | + | Classes []string | |
| 21 | + | State StateFlags | |
| 22 | + | Inline *Declarations | |
| 23 | + | } | |
| 24 | + | type Specificity struct{ IDs, Classes, Types int } | |
| 25 | + | ||
| 26 | + | func (s Specificity) greater(t Specificity) bool { | |
| 27 | + | if s.IDs != t.IDs { | |
| 28 | + | return s.IDs > t.IDs | |
| 29 | + | } | |
| 30 | + | if s.Classes != t.Classes { | |
| 31 | + | return s.Classes > t.Classes | |
| 32 | + | } | |
| 33 | + | return s.Types > t.Types | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type compound struct { | |
| 37 | + | typ, id string | |
| 38 | + | classes, pseudos []string | |
| 39 | + | combinator string | |
| 40 | + | typID, idID uint32 | |
| 41 | + | classIDs []uint32 | |
| 42 | + | state StateFlags | |
| 43 | + | } | |
| 44 | + | type Selector struct { | |
| 45 | + | parts []compound | |
| 46 | + | Specificity Specificity | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func parseSelectors(ts []Token) ([]Selector, bool) { | |
| 50 | + | var result []Selector | |
| 51 | + | start := 0 | |
| 52 | + | for i := 0; i <= len(ts); i++ { | |
| 53 | + | if i == len(ts) || ts[i].Kind == "," { | |
| 54 | + | s, ok := parseSelector(ts[start:i]) | |
| 55 | + | if !ok { | |
| 56 | + | return nil, false | |
| 57 | + | } | |
| 58 | + | result = append(result, s) | |
| 59 | + | start = i + 1 | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return result, len(result) > 0 | |
| 63 | + | } | |
| 64 | + | func parseSelector(ts []Token) (Selector, bool) { | |
| 65 | + | s := Selector{} | |
| 66 | + | i := 0 | |
| 67 | + | pending := "" | |
| 68 | + | for i < len(ts) { | |
| 69 | + | space := false | |
| 70 | + | for i < len(ts) && ts[i].Kind == "space" { | |
| 71 | + | space = true | |
| 72 | + | i++ | |
| 73 | + | } | |
| 74 | + | if space && len(s.parts) > 0 && pending == "" { | |
| 75 | + | pending = " " | |
| 76 | + | } | |
| 77 | + | if i >= len(ts) { | |
| 78 | + | break | |
| 79 | + | } | |
| 80 | + | if ts[i].Kind == ">" { | |
| 81 | + | if len(s.parts) == 0 || pending == ">" { | |
| 82 | + | return s, false | |
| 83 | + | } | |
| 84 | + | pending = ">" | |
| 85 | + | i++ | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | p := compound{combinator: pending} | |
| 89 | + | pending = "" | |
| 90 | + | seen := false | |
| 91 | + | for i < len(ts) { | |
| 92 | + | t := ts[i] | |
| 93 | + | if t.Kind == "space" || t.Kind == ">" { | |
| 94 | + | break | |
| 95 | + | } | |
| 96 | + | switch t.Kind { | |
| 97 | + | case "hash": | |
| 98 | + | if p.id != "" { | |
| 99 | + | return s, false | |
| 100 | + | } | |
| 101 | + | p.id = t.Value | |
| 102 | + | s.Specificity.IDs++ | |
| 103 | + | seen = true | |
| 104 | + | i++ | |
| 105 | + | case "ident": | |
| 106 | + | if seen || p.typ != "" { | |
| 107 | + | return s, false | |
| 108 | + | } | |
| 109 | + | p.typ = strings.ToLower(t.Value) | |
| 110 | + | s.Specificity.Types++ | |
| 111 | + | seen = true | |
| 112 | + | i++ | |
| 113 | + | case ".", "#", ":": | |
| 114 | + | if i+1 >= len(ts) || ts[i+1].Kind != "ident" { | |
| 115 | + | return s, false | |
| 116 | + | } | |
| 117 | + | v := ts[i+1].Value | |
| 118 | + | switch t.Kind { | |
| 119 | + | case ".": | |
| 120 | + | p.classes = append(p.classes, v) | |
| 121 | + | s.Specificity.Classes++ | |
| 122 | + | case "#": | |
| 123 | + | if p.id != "" { | |
| 124 | + | return s, false | |
| 125 | + | } | |
| 126 | + | p.id = v | |
| 127 | + | s.Specificity.IDs++ | |
| 128 | + | case ":": | |
| 129 | + | switch strings.ToLower(v) { | |
| 130 | + | case "hover", "active", "focus", "focus-visible", "disabled", "checked": | |
| 131 | + | p.pseudos = append(p.pseudos, strings.ToLower(v)) | |
| 132 | + | s.Specificity.Classes++ | |
| 133 | + | default: | |
| 134 | + | return s, false | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | seen = true | |
| 138 | + | i += 2 | |
| 139 | + | default: | |
| 140 | + | return s, false | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | if !seen || len(s.parts) > 0 && p.combinator == "" { | |
| 144 | + | return s, false | |
| 145 | + | } | |
| 146 | + | s.parts = append(s.parts, p) | |
| 147 | + | } | |
| 148 | + | return s, len(s.parts) > 0 && pending != ">" | |
| 149 | + | } | |
A internal/css/style.go +553 -0
| @@ -0,0 +1,553 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | // Keyword is a computed CSS enumerated value. The documented property grammar | |
| 4 | + | // supplies the possible values for each field. | |
| 5 | + | type Keyword uint8 | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | KeywordNone Keyword = iota | |
| 9 | + | KeywordAuto | |
| 10 | + | KeywordBlock | |
| 11 | + | KeywordFlex | |
| 12 | + | KeywordContentBox | |
| 13 | + | KeywordBorderBox | |
| 14 | + | KeywordRow | |
| 15 | + | KeywordColumn | |
| 16 | + | KeywordNowrap | |
| 17 | + | KeywordWrap | |
| 18 | + | KeywordFlexStart | |
| 19 | + | KeywordFlexEnd | |
| 20 | + | KeywordCenter | |
| 21 | + | KeywordSpaceBetween | |
| 22 | + | KeywordSpaceAround | |
| 23 | + | KeywordSpaceEvenly | |
| 24 | + | KeywordStretch | |
| 25 | + | KeywordVisible | |
| 26 | + | KeywordHidden | |
| 27 | + | KeywordScroll | |
| 28 | + | KeywordNormal | |
| 29 | + | KeywordItalic | |
| 30 | + | KeywordOblique | |
| 31 | + | KeywordStart | |
| 32 | + | KeywordEnd | |
| 33 | + | KeywordLeft | |
| 34 | + | KeywordRight | |
| 35 | + | KeywordDefault | |
| 36 | + | KeywordPointer | |
| 37 | + | KeywordText | |
| 38 | + | KeywordNotAllowed | |
| 39 | + | KeywordSolid | |
| 40 | + | KeywordNumber | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | var keywordNames = []string{"none", "auto", "block", "flex", "content-box", "border-box", "row", "column", "nowrap", "wrap", "flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly", "stretch", "visible", "hidden", "scroll", "normal", "italic", "oblique", "start", "end", "left", "right", "default", "pointer", "text", "not-allowed", "solid", "number"} | |
| 44 | + | ||
| 45 | + | func keyword(s string) Keyword { | |
| 46 | + | for i, k := range keywordNames { | |
| 47 | + | if k == s { | |
| 48 | + | return Keyword(i) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | return KeywordNone | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Sides and Corners follow clockwise CSS order. | |
| 55 | + | type LengthSides struct{ Top, Right, Bottom, Left Length } | |
| 56 | + | type ColorSides struct{ Top, Right, Bottom, Left Color } | |
| 57 | + | type KeywordSides struct{ Top, Right, Bottom, Left Keyword } | |
| 58 | + | type Radii struct{ TopLeft, TopRight, BottomRight, BottomLeft Length } | |
| 59 | + | ||
| 60 | + | // Style is a computed, immutable value. Slice backing arrays are shared: do not | |
| 61 | + | // mutate a returned Style or any of its slices. | |
| 62 | + | type Style struct { | |
| 63 | + | Color Color | |
| 64 | + | Opacity float64 | |
| 65 | + | BackgroundColor Color | |
| 66 | + | BorderWidth LengthSides | |
| 67 | + | BorderStyle KeywordSides | |
| 68 | + | BorderColor ColorSides | |
| 69 | + | BorderRadius Radii | |
| 70 | + | OutlineWidth Length | |
| 71 | + | OutlineStyle Keyword | |
| 72 | + | OutlineColor Color | |
| 73 | + | OutlineOffset Length | |
| 74 | + | BoxShadow []Shadow | |
| 75 | + | Margin, Padding LengthSides | |
| 76 | + | Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight Length | |
| 77 | + | BoxSizing, Display, FlexDirection, FlexWrap Keyword | |
| 78 | + | FlexGrow, FlexShrink float64 | |
| 79 | + | FlexBasis, RowGap, ColumnGap Length | |
| 80 | + | JustifyContent, AlignItems, AlignSelf, OverflowX, OverflowY Keyword | |
| 81 | + | FontFamily []string | |
| 82 | + | FontSize Length | |
| 83 | + | FontWeight float64 | |
| 84 | + | FontStyle Keyword | |
| 85 | + | LineHeight Length | |
| 86 | + | LineHeightNumber float64 | |
| 87 | + | LineHeightKind Keyword | |
| 88 | + | TextAlign Keyword | |
| 89 | + | LetterSpacing Length | |
| 90 | + | LetterSpacingNormal bool | |
| 91 | + | Cursor Keyword | |
| 92 | + | AccentColor Color // checkbox, radio and slider indicators | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // propertyID indexes the fixed cascade winner array. | |
| 96 | + | type propertyID uint8 | |
| 97 | + | ||
| 98 | + | const ( | |
| 99 | + | p_color propertyID = iota | |
| 100 | + | p_opacity | |
| 101 | + | p_background_color | |
| 102 | + | p_outline_width | |
| 103 | + | p_outline_style | |
| 104 | + | p_outline_color | |
| 105 | + | p_outline_offset | |
| 106 | + | p_box_shadow | |
| 107 | + | p_width | |
| 108 | + | p_height | |
| 109 | + | p_min_width | |
| 110 | + | p_min_height | |
| 111 | + | p_max_width | |
| 112 | + | p_max_height | |
| 113 | + | p_box_sizing | |
| 114 | + | p_display | |
| 115 | + | p_flex_direction | |
| 116 | + | p_flex_wrap | |
| 117 | + | p_flex_grow | |
| 118 | + | p_flex_shrink | |
| 119 | + | p_flex_basis | |
| 120 | + | p_row_gap | |
| 121 | + | p_column_gap | |
| 122 | + | p_justify_content | |
| 123 | + | p_align_items | |
| 124 | + | p_align_self | |
| 125 | + | p_overflow_x | |
| 126 | + | p_overflow_y | |
| 127 | + | p_font_family | |
| 128 | + | p_font_size | |
| 129 | + | p_font_weight | |
| 130 | + | p_font_style | |
| 131 | + | p_line_height | |
| 132 | + | p_text_align | |
| 133 | + | p_letter_spacing | |
| 134 | + | p_cursor | |
| 135 | + | p_margin_top | |
| 136 | + | p_padding_top | |
| 137 | + | p_border_top_width | |
| 138 | + | p_border_top_style | |
| 139 | + | p_border_top_color | |
| 140 | + | p_margin_right | |
| 141 | + | p_padding_right | |
| 142 | + | p_border_right_width | |
| 143 | + | p_border_right_style | |
| 144 | + | p_border_right_color | |
| 145 | + | p_margin_bottom | |
| 146 | + | p_padding_bottom | |
| 147 | + | p_border_bottom_width | |
| 148 | + | p_border_bottom_style | |
| 149 | + | p_border_bottom_color | |
| 150 | + | p_margin_left | |
| 151 | + | p_padding_left | |
| 152 | + | p_border_left_width | |
| 153 | + | p_border_left_style | |
| 154 | + | p_border_left_color | |
| 155 | + | p_border_top_left_radius | |
| 156 | + | p_border_top_right_radius | |
| 157 | + | p_border_bottom_right_radius | |
| 158 | + | p_border_bottom_left_radius | |
| 159 | + | p_accent_color | |
| 160 | + | propertyCount | |
| 161 | + | ) | |
| 162 | + | ||
| 163 | + | var propertyNames = [...]string{"color", "opacity", "background-color", "outline-width", "outline-style", "outline-color", "outline-offset", "box-shadow", "width", "height", "min-width", "min-height", "max-width", "max-height", "box-sizing", "display", "flex-direction", "flex-wrap", "flex-grow", "flex-shrink", "flex-basis", "row-gap", "column-gap", "justify-content", "align-items", "align-self", "overflow-x", "overflow-y", "font-family", "font-size", "font-weight", "font-style", "line-height", "text-align", "letter-spacing", "cursor", "margin-top", "padding-top", "border-top-width", "border-top-style", "border-top-color", "margin-right", "padding-right", "border-right-width", "border-right-style", "border-right-color", "margin-bottom", "padding-bottom", "border-bottom-width", "border-bottom-style", "border-bottom-color", "margin-left", "padding-left", "border-left-width", "border-left-style", "border-left-color", "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius", "accent-color"} | |
| 164 | + | ||
| 165 | + | func propertyIndex(n string) (propertyID, bool) { | |
| 166 | + | switch n { | |
| 167 | + | case "color": | |
| 168 | + | return p_color, true | |
| 169 | + | case "opacity": | |
| 170 | + | return p_opacity, true | |
| 171 | + | case "background-color": | |
| 172 | + | return p_background_color, true | |
| 173 | + | case "outline-width": | |
| 174 | + | return p_outline_width, true | |
| 175 | + | case "outline-style": | |
| 176 | + | return p_outline_style, true | |
| 177 | + | case "outline-color": | |
| 178 | + | return p_outline_color, true | |
| 179 | + | case "outline-offset": | |
| 180 | + | return p_outline_offset, true | |
| 181 | + | case "box-shadow": | |
| 182 | + | return p_box_shadow, true | |
| 183 | + | case "width": | |
| 184 | + | return p_width, true | |
| 185 | + | case "height": | |
| 186 | + | return p_height, true | |
| 187 | + | case "min-width": | |
| 188 | + | return p_min_width, true | |
| 189 | + | case "min-height": | |
| 190 | + | return p_min_height, true | |
| 191 | + | case "max-width": | |
| 192 | + | return p_max_width, true | |
| 193 | + | case "max-height": | |
| 194 | + | return p_max_height, true | |
| 195 | + | case "box-sizing": | |
| 196 | + | return p_box_sizing, true | |
| 197 | + | case "display": | |
| 198 | + | return p_display, true | |
| 199 | + | case "flex-direction": | |
| 200 | + | return p_flex_direction, true | |
| 201 | + | case "flex-wrap": | |
| 202 | + | return p_flex_wrap, true | |
| 203 | + | case "flex-grow": | |
| 204 | + | return p_flex_grow, true | |
| 205 | + | case "flex-shrink": | |
| 206 | + | return p_flex_shrink, true | |
| 207 | + | case "flex-basis": | |
| 208 | + | return p_flex_basis, true | |
| 209 | + | case "row-gap": | |
| 210 | + | return p_row_gap, true | |
| 211 | + | case "column-gap": | |
| 212 | + | return p_column_gap, true | |
| 213 | + | case "justify-content": | |
| 214 | + | return p_justify_content, true | |
| 215 | + | case "align-items": | |
| 216 | + | return p_align_items, true | |
| 217 | + | case "align-self": | |
| 218 | + | return p_align_self, true | |
| 219 | + | case "overflow-x": | |
| 220 | + | return p_overflow_x, true | |
| 221 | + | case "overflow-y": | |
| 222 | + | return p_overflow_y, true | |
| 223 | + | case "font-family": | |
| 224 | + | return p_font_family, true | |
| 225 | + | case "font-size": | |
| 226 | + | return p_font_size, true | |
| 227 | + | case "font-weight": | |
| 228 | + | return p_font_weight, true | |
| 229 | + | case "font-style": | |
| 230 | + | return p_font_style, true | |
| 231 | + | case "line-height": | |
| 232 | + | return p_line_height, true | |
| 233 | + | case "text-align": | |
| 234 | + | return p_text_align, true | |
| 235 | + | case "letter-spacing": | |
| 236 | + | return p_letter_spacing, true | |
| 237 | + | case "cursor": | |
| 238 | + | return p_cursor, true | |
| 239 | + | case "margin-top": | |
| 240 | + | return p_margin_top, true | |
| 241 | + | case "padding-top": | |
| 242 | + | return p_padding_top, true | |
| 243 | + | case "border-top-width": | |
| 244 | + | return p_border_top_width, true | |
| 245 | + | case "border-top-style": | |
| 246 | + | return p_border_top_style, true | |
| 247 | + | case "border-top-color": | |
| 248 | + | return p_border_top_color, true | |
| 249 | + | case "margin-right": | |
| 250 | + | return p_margin_right, true | |
| 251 | + | case "padding-right": | |
| 252 | + | return p_padding_right, true | |
| 253 | + | case "border-right-width": | |
| 254 | + | return p_border_right_width, true | |
| 255 | + | case "border-right-style": | |
| 256 | + | return p_border_right_style, true | |
| 257 | + | case "border-right-color": | |
| 258 | + | return p_border_right_color, true | |
| 259 | + | case "margin-bottom": | |
| 260 | + | return p_margin_bottom, true | |
| 261 | + | case "padding-bottom": | |
| 262 | + | return p_padding_bottom, true | |
| 263 | + | case "border-bottom-width": | |
| 264 | + | return p_border_bottom_width, true | |
| 265 | + | case "border-bottom-style": | |
| 266 | + | return p_border_bottom_style, true | |
| 267 | + | case "border-bottom-color": | |
| 268 | + | return p_border_bottom_color, true | |
| 269 | + | case "margin-left": | |
| 270 | + | return p_margin_left, true | |
| 271 | + | case "padding-left": | |
| 272 | + | return p_padding_left, true | |
| 273 | + | case "border-left-width": | |
| 274 | + | return p_border_left_width, true | |
| 275 | + | case "border-left-style": | |
| 276 | + | return p_border_left_style, true | |
| 277 | + | case "border-left-color": | |
| 278 | + | return p_border_left_color, true | |
| 279 | + | case "border-top-left-radius": | |
| 280 | + | return p_border_top_left_radius, true | |
| 281 | + | case "border-top-right-radius": | |
| 282 | + | return p_border_top_right_radius, true | |
| 283 | + | case "border-bottom-right-radius": | |
| 284 | + | return p_border_bottom_right_radius, true | |
| 285 | + | case "border-bottom-left-radius": | |
| 286 | + | return p_border_bottom_left_radius, true | |
| 287 | + | case "accent-color": | |
| 288 | + | return p_accent_color, true | |
| 289 | + | } | |
| 290 | + | return 0, false | |
| 291 | + | } | |
| 292 | + | func (s *Style) set(n propertyID, v Value) { | |
| 293 | + | switch n { | |
| 294 | + | case p_color: | |
| 295 | + | s.Color = v.Color | |
| 296 | + | case p_opacity: | |
| 297 | + | s.Opacity = v.Number | |
| 298 | + | case p_background_color: | |
| 299 | + | s.BackgroundColor = v.Color | |
| 300 | + | case p_outline_width: | |
| 301 | + | s.OutlineWidth = v.Length | |
| 302 | + | case p_outline_style: | |
| 303 | + | s.OutlineStyle = keyword(v.Keyword) | |
| 304 | + | case p_outline_color: | |
| 305 | + | s.OutlineColor = v.Color | |
| 306 | + | case p_outline_offset: | |
| 307 | + | s.OutlineOffset = v.Length | |
| 308 | + | case p_box_shadow: | |
| 309 | + | s.BoxShadow = v.Shadows | |
| 310 | + | case p_width: | |
| 311 | + | s.Width = v.Length | |
| 312 | + | case p_height: | |
| 313 | + | s.Height = v.Length | |
| 314 | + | case p_min_width: | |
| 315 | + | s.MinWidth = v.Length | |
| 316 | + | case p_min_height: | |
| 317 | + | s.MinHeight = v.Length | |
| 318 | + | case p_max_width: | |
| 319 | + | s.MaxWidth = v.Length | |
| 320 | + | case p_max_height: | |
| 321 | + | s.MaxHeight = v.Length | |
| 322 | + | case p_box_sizing: | |
| 323 | + | s.BoxSizing = keyword(v.Keyword) | |
| 324 | + | case p_display: | |
| 325 | + | s.Display = keyword(v.Keyword) | |
| 326 | + | case p_flex_direction: | |
| 327 | + | s.FlexDirection = keyword(v.Keyword) | |
| 328 | + | case p_flex_wrap: | |
| 329 | + | s.FlexWrap = keyword(v.Keyword) | |
| 330 | + | case p_flex_grow: | |
| 331 | + | s.FlexGrow = v.Number | |
| 332 | + | case p_flex_shrink: | |
| 333 | + | s.FlexShrink = v.Number | |
| 334 | + | case p_flex_basis: | |
| 335 | + | s.FlexBasis = v.Length | |
| 336 | + | case p_row_gap: | |
| 337 | + | s.RowGap = v.Length | |
| 338 | + | case p_column_gap: | |
| 339 | + | s.ColumnGap = v.Length | |
| 340 | + | case p_justify_content: | |
| 341 | + | s.JustifyContent = keyword(v.Keyword) | |
| 342 | + | case p_align_items: | |
| 343 | + | s.AlignItems = keyword(v.Keyword) | |
| 344 | + | case p_align_self: | |
| 345 | + | s.AlignSelf = keyword(v.Keyword) | |
| 346 | + | case p_overflow_x: | |
| 347 | + | s.OverflowX = keyword(v.Keyword) | |
| 348 | + | case p_overflow_y: | |
| 349 | + | s.OverflowY = keyword(v.Keyword) | |
| 350 | + | case p_font_family: | |
| 351 | + | s.FontFamily = v.Families | |
| 352 | + | case p_font_size: | |
| 353 | + | s.FontSize = v.Length | |
| 354 | + | case p_font_weight: | |
| 355 | + | s.FontWeight = v.Number | |
| 356 | + | case p_font_style: | |
| 357 | + | s.FontStyle = keyword(v.Keyword) | |
| 358 | + | case p_line_height: | |
| 359 | + | s.LineHeight = v.Length | |
| 360 | + | s.LineHeightKind = keyword(v.Keyword) | |
| 361 | + | s.LineHeightNumber = v.Number | |
| 362 | + | case p_text_align: | |
| 363 | + | s.TextAlign = keyword(v.Keyword) | |
| 364 | + | case p_letter_spacing: | |
| 365 | + | s.LetterSpacing = v.Length | |
| 366 | + | s.LetterSpacingNormal = v.Keyword == "normal" | |
| 367 | + | case p_cursor: | |
| 368 | + | s.Cursor = keyword(v.Keyword) | |
| 369 | + | case p_margin_top: | |
| 370 | + | s.Margin.Top = v.Length | |
| 371 | + | case p_padding_top: | |
| 372 | + | s.Padding.Top = v.Length | |
| 373 | + | case p_border_top_width: | |
| 374 | + | s.BorderWidth.Top = v.Length | |
| 375 | + | case p_border_top_style: | |
| 376 | + | s.BorderStyle.Top = keyword(v.Keyword) | |
| 377 | + | case p_border_top_color: | |
| 378 | + | s.BorderColor.Top = v.Color | |
| 379 | + | case p_margin_right: | |
| 380 | + | s.Margin.Right = v.Length | |
| 381 | + | case p_padding_right: | |
| 382 | + | s.Padding.Right = v.Length | |
| 383 | + | case p_border_right_width: | |
| 384 | + | s.BorderWidth.Right = v.Length | |
| 385 | + | case p_border_right_style: | |
| 386 | + | s.BorderStyle.Right = keyword(v.Keyword) | |
| 387 | + | case p_border_right_color: | |
| 388 | + | s.BorderColor.Right = v.Color | |
| 389 | + | case p_margin_bottom: | |
| 390 | + | s.Margin.Bottom = v.Length | |
| 391 | + | case p_padding_bottom: | |
| 392 | + | s.Padding.Bottom = v.Length | |
| 393 | + | case p_border_bottom_width: | |
| 394 | + | s.BorderWidth.Bottom = v.Length | |
| 395 | + | case p_border_bottom_style: | |
| 396 | + | s.BorderStyle.Bottom = keyword(v.Keyword) | |
| 397 | + | case p_border_bottom_color: | |
| 398 | + | s.BorderColor.Bottom = v.Color | |
| 399 | + | case p_margin_left: | |
| 400 | + | s.Margin.Left = v.Length | |
| 401 | + | case p_padding_left: | |
| 402 | + | s.Padding.Left = v.Length | |
| 403 | + | case p_border_left_width: | |
| 404 | + | s.BorderWidth.Left = v.Length | |
| 405 | + | case p_border_left_style: | |
| 406 | + | s.BorderStyle.Left = keyword(v.Keyword) | |
| 407 | + | case p_border_left_color: | |
| 408 | + | s.BorderColor.Left = v.Color | |
| 409 | + | case p_border_top_left_radius: | |
| 410 | + | s.BorderRadius.TopLeft = v.Length | |
| 411 | + | case p_border_top_right_radius: | |
| 412 | + | s.BorderRadius.TopRight = v.Length | |
| 413 | + | case p_border_bottom_right_radius: | |
| 414 | + | s.BorderRadius.BottomRight = v.Length | |
| 415 | + | case p_border_bottom_left_radius: | |
| 416 | + | s.BorderRadius.BottomLeft = v.Length | |
| 417 | + | case p_accent_color: | |
| 418 | + | s.AccentColor = v.Color | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | func (s *Style) value(n propertyID) Value { | |
| 422 | + | var v Value | |
| 423 | + | switch n { | |
| 424 | + | case p_color: | |
| 425 | + | v.Color = s.Color | |
| 426 | + | case p_opacity: | |
| 427 | + | v.Number = s.Opacity | |
| 428 | + | case p_background_color: | |
| 429 | + | v.Color = s.BackgroundColor | |
| 430 | + | case p_outline_width: | |
| 431 | + | v.Length = s.OutlineWidth | |
| 432 | + | case p_outline_style: | |
| 433 | + | v.Keyword = keywordNames[s.OutlineStyle] | |
| 434 | + | case p_outline_color: | |
| 435 | + | v.Color = s.OutlineColor | |
| 436 | + | case p_outline_offset: | |
| 437 | + | v.Length = s.OutlineOffset | |
| 438 | + | case p_box_shadow: | |
| 439 | + | v.Shadows = s.BoxShadow | |
| 440 | + | case p_width: | |
| 441 | + | v.Length = s.Width | |
| 442 | + | case p_height: | |
| 443 | + | v.Length = s.Height | |
| 444 | + | case p_min_width: | |
| 445 | + | v.Length = s.MinWidth | |
| 446 | + | case p_min_height: | |
| 447 | + | v.Length = s.MinHeight | |
| 448 | + | case p_max_width: | |
| 449 | + | v.Length = s.MaxWidth | |
| 450 | + | case p_max_height: | |
| 451 | + | v.Length = s.MaxHeight | |
| 452 | + | case p_box_sizing: | |
| 453 | + | v.Keyword = keywordNames[s.BoxSizing] | |
| 454 | + | case p_display: | |
| 455 | + | v.Keyword = keywordNames[s.Display] | |
| 456 | + | case p_flex_direction: | |
| 457 | + | v.Keyword = keywordNames[s.FlexDirection] | |
| 458 | + | case p_flex_wrap: | |
| 459 | + | v.Keyword = keywordNames[s.FlexWrap] | |
| 460 | + | case p_flex_grow: | |
| 461 | + | v.Number = s.FlexGrow | |
| 462 | + | case p_flex_shrink: | |
| 463 | + | v.Number = s.FlexShrink | |
| 464 | + | case p_flex_basis: | |
| 465 | + | v.Length = s.FlexBasis | |
| 466 | + | case p_row_gap: | |
| 467 | + | v.Length = s.RowGap | |
| 468 | + | case p_column_gap: | |
| 469 | + | v.Length = s.ColumnGap | |
| 470 | + | case p_justify_content: | |
| 471 | + | v.Keyword = keywordNames[s.JustifyContent] | |
| 472 | + | case p_align_items: | |
| 473 | + | v.Keyword = keywordNames[s.AlignItems] | |
| 474 | + | case p_align_self: | |
| 475 | + | v.Keyword = keywordNames[s.AlignSelf] | |
| 476 | + | case p_overflow_x: | |
| 477 | + | v.Keyword = keywordNames[s.OverflowX] | |
| 478 | + | case p_overflow_y: | |
| 479 | + | v.Keyword = keywordNames[s.OverflowY] | |
| 480 | + | case p_font_family: | |
| 481 | + | v.Families = s.FontFamily | |
| 482 | + | case p_font_size: | |
| 483 | + | v.Length = s.FontSize | |
| 484 | + | case p_font_weight: | |
| 485 | + | v.Number = s.FontWeight | |
| 486 | + | case p_font_style: | |
| 487 | + | v.Keyword = keywordNames[s.FontStyle] | |
| 488 | + | case p_line_height: | |
| 489 | + | v.Length = s.LineHeight | |
| 490 | + | v.Keyword = keywordNames[s.LineHeightKind] | |
| 491 | + | v.Number = s.LineHeightNumber | |
| 492 | + | case p_text_align: | |
| 493 | + | v.Keyword = keywordNames[s.TextAlign] | |
| 494 | + | case p_letter_spacing: | |
| 495 | + | v.Length = s.LetterSpacing | |
| 496 | + | if s.LetterSpacingNormal { | |
| 497 | + | v.Keyword = "normal" | |
| 498 | + | } | |
| 499 | + | case p_cursor: | |
| 500 | + | v.Keyword = keywordNames[s.Cursor] | |
| 501 | + | case p_margin_top: | |
| 502 | + | v.Length = s.Margin.Top | |
| 503 | + | case p_padding_top: | |
| 504 | + | v.Length = s.Padding.Top | |
| 505 | + | case p_border_top_width: | |
| 506 | + | v.Length = s.BorderWidth.Top | |
| 507 | + | case p_border_top_style: | |
| 508 | + | v.Keyword = keywordNames[s.BorderStyle.Top] | |
| 509 | + | case p_border_top_color: | |
| 510 | + | v.Color = s.BorderColor.Top | |
| 511 | + | case p_margin_right: | |
| 512 | + | v.Length = s.Margin.Right | |
| 513 | + | case p_padding_right: | |
| 514 | + | v.Length = s.Padding.Right | |
| 515 | + | case p_border_right_width: | |
| 516 | + | v.Length = s.BorderWidth.Right | |
| 517 | + | case p_border_right_style: | |
| 518 | + | v.Keyword = keywordNames[s.BorderStyle.Right] | |
| 519 | + | case p_border_right_color: | |
| 520 | + | v.Color = s.BorderColor.Right | |
| 521 | + | case p_margin_bottom: | |
| 522 | + | v.Length = s.Margin.Bottom | |
| 523 | + | case p_padding_bottom: | |
| 524 | + | v.Length = s.Padding.Bottom | |
| 525 | + | case p_border_bottom_width: | |
| 526 | + | v.Length = s.BorderWidth.Bottom | |
| 527 | + | case p_border_bottom_style: | |
| 528 | + | v.Keyword = keywordNames[s.BorderStyle.Bottom] | |
| 529 | + | case p_border_bottom_color: | |
| 530 | + | v.Color = s.BorderColor.Bottom | |
| 531 | + | case p_margin_left: | |
| 532 | + | v.Length = s.Margin.Left | |
| 533 | + | case p_padding_left: | |
| 534 | + | v.Length = s.Padding.Left | |
| 535 | + | case p_border_left_width: | |
| 536 | + | v.Length = s.BorderWidth.Left | |
| 537 | + | case p_border_left_style: | |
| 538 | + | v.Keyword = keywordNames[s.BorderStyle.Left] | |
| 539 | + | case p_border_left_color: | |
| 540 | + | v.Color = s.BorderColor.Left | |
| 541 | + | case p_border_top_left_radius: | |
| 542 | + | v.Length = s.BorderRadius.TopLeft | |
| 543 | + | case p_border_top_right_radius: | |
| 544 | + | v.Length = s.BorderRadius.TopRight | |
| 545 | + | case p_border_bottom_right_radius: | |
| 546 | + | v.Length = s.BorderRadius.BottomRight | |
| 547 | + | case p_border_bottom_left_radius: | |
| 548 | + | v.Length = s.BorderRadius.BottomLeft | |
| 549 | + | case p_accent_color: | |
| 550 | + | v.Color = s.AccentColor | |
| 551 | + | } | |
| 552 | + | return v | |
| 553 | + | } | |
A internal/css/testdata/basic.css +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | app { color: #123; font-size: 20px; --tone: rebeccapurple; } | |
| 2 | + | app > row { padding: 1em 2px; background: var(--tone); } | |
| 3 | + | row:hover { background: rgb(100% 0% 0% / 50%); } | |
A internal/css/testdata/basic.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"display":"flex","padding-top":{"Value":20,"Unit":"px"},"padding-right":{"Value":2,"Unit":"px"},"background-color":{"R":0.4,"G":0.2,"B":0.6,"A":1},"font-size":{"Value":20,"Unit":"px"}} | |
A internal/css/testdata/fuzz/FuzzTokenizerParser/0d9df0c9d84e98b9 +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | go test fuzz v1 | |
| 2 | + | string("A{Color:\xd3vAr(}") | |
A internal/css/token.go +295 -0
| @@ -0,0 +1,295 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "unicode" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Token is a CSS syntax token. Raw preserves the original spelling for value parsing. | |
| 10 | + | type Token struct { | |
| 11 | + | Kind, Raw, Value string | |
| 12 | + | Line, Col int | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // Tokenize scans CSS without panicking on malformed UTF-8 or unterminated constructs. | |
| 16 | + | func Tokenize(src string) []Token { | |
| 17 | + | var out []Token | |
| 18 | + | line, col := 1, 1 | |
| 19 | + | take := func(n int) string { | |
| 20 | + | s := src[:n] | |
| 21 | + | for _, r := range s { | |
| 22 | + | if r == '\n' { | |
| 23 | + | line++ | |
| 24 | + | col = 1 | |
| 25 | + | } else { | |
| 26 | + | col++ | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | src = src[n:] | |
| 30 | + | return s | |
| 31 | + | } | |
| 32 | + | emit := func(k, s, v string, l, c int) { out = append(out, Token{k, s, v, l, c}) } | |
| 33 | + | for len(src) > 0 { | |
| 34 | + | l, c := line, col | |
| 35 | + | if strings.HasPrefix(src, "/*") { | |
| 36 | + | i := strings.Index(src[2:], "*/") | |
| 37 | + | if i < 0 { | |
| 38 | + | take(len(src)) | |
| 39 | + | } else { | |
| 40 | + | take(i + 4) | |
| 41 | + | } | |
| 42 | + | // Comments separate tokens; raw() must preserve that separation. | |
| 43 | + | emit("space", " ", " ", l, c) | |
| 44 | + | continue | |
| 45 | + | } | |
| 46 | + | r, n := utf8.DecodeRuneInString(src) | |
| 47 | + | if unicode.IsSpace(r) { | |
| 48 | + | i := n | |
| 49 | + | for i < len(src) { | |
| 50 | + | rr, nn := utf8.DecodeRuneInString(src[i:]) | |
| 51 | + | if !unicode.IsSpace(rr) { | |
| 52 | + | break | |
| 53 | + | } | |
| 54 | + | i += nn | |
| 55 | + | } | |
| 56 | + | emit("space", take(i), " ", l, c) | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | if r == '\'' || r == '"' { | |
| 60 | + | quote := r | |
| 61 | + | i := n | |
| 62 | + | var val strings.Builder | |
| 63 | + | bad := false | |
| 64 | + | closed := false | |
| 65 | + | for i < len(src) { | |
| 66 | + | rr, nn := utf8.DecodeRuneInString(src[i:]) | |
| 67 | + | if rr == quote { | |
| 68 | + | i += nn | |
| 69 | + | closed = true | |
| 70 | + | break | |
| 71 | + | } | |
| 72 | + | if rr == '\n' { | |
| 73 | + | bad = true | |
| 74 | + | break | |
| 75 | + | } | |
| 76 | + | if rr == '\\' { | |
| 77 | + | i += nn | |
| 78 | + | if i < len(src) { | |
| 79 | + | er, en := escape(src[i:]) | |
| 80 | + | val.WriteRune(er) | |
| 81 | + | i += en | |
| 82 | + | } | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | val.WriteRune(rr) | |
| 86 | + | i += nn | |
| 87 | + | } | |
| 88 | + | if !closed { | |
| 89 | + | bad = true | |
| 90 | + | } | |
| 91 | + | s := take(i) | |
| 92 | + | k := "string" | |
| 93 | + | if bad { | |
| 94 | + | k = "bad-string" | |
| 95 | + | } | |
| 96 | + | emit(k, s, val.String(), l, c) | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | if r == '#' && len(src) > n && identStart(src[n:]) { | |
| 100 | + | i := n + identLen(src[n:]) | |
| 101 | + | s := take(i) | |
| 102 | + | emit("hash", s, unescapeIdent(s[n:]), l, c) | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | if numberStart(src) { | |
| 106 | + | i := numberLen(src) | |
| 107 | + | k := "number" | |
| 108 | + | if i < len(src) && src[i] == '%' { | |
| 109 | + | i++ | |
| 110 | + | k = "percentage" | |
| 111 | + | } else if i < len(src) && identStart(src[i:]) { | |
| 112 | + | i += identLen(src[i:]) | |
| 113 | + | k = "dimension" | |
| 114 | + | } | |
| 115 | + | s := take(i) | |
| 116 | + | emit(k, s, s, l, c) | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | if identStart(src) { | |
| 120 | + | i := identLen(src) | |
| 121 | + | k := "ident" | |
| 122 | + | if i < len(src) && src[i] == '(' { | |
| 123 | + | i++ | |
| 124 | + | k = "function" | |
| 125 | + | } | |
| 126 | + | s := take(i) | |
| 127 | + | value := s | |
| 128 | + | if k == "ident" || k == "function" { | |
| 129 | + | value = unescapeIdent(strings.TrimSuffix(s, "(")) | |
| 130 | + | } | |
| 131 | + | if k == "function" && strings.EqualFold(value, "url") { | |
| 132 | + | // CSS Syntax: unquoted url() consumes to ')' or EOF, recovering bad-url. | |
| 133 | + | rest := src | |
| 134 | + | off := 0 | |
| 135 | + | for off < len(rest) && (rest[off] == ' ' || rest[off] == '\n' || rest[off] == '\t') { | |
| 136 | + | off++ | |
| 137 | + | } | |
| 138 | + | if off < len(rest) && rest[off] != '\'' && rest[off] != '"' { | |
| 139 | + | start := off | |
| 140 | + | bad := false | |
| 141 | + | for off < len(rest) && rest[off] != ')' { | |
| 142 | + | if rest[off] == '"' || rest[off] == '\'' || rest[off] == '(' || rest[off] == '\n' { | |
| 143 | + | bad = true | |
| 144 | + | } | |
| 145 | + | if rest[off] == '\\' { | |
| 146 | + | if off+1 >= len(rest) || rest[off+1] == '\n' { | |
| 147 | + | bad = true | |
| 148 | + | } else { | |
| 149 | + | off++ | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | off++ | |
| 153 | + | } | |
| 154 | + | text := strings.TrimSpace(rest[start:off]) | |
| 155 | + | if off < len(rest) { | |
| 156 | + | off++ | |
| 157 | + | } else { | |
| 158 | + | bad = true | |
| 159 | + | } | |
| 160 | + | s += take(off) | |
| 161 | + | kind := "url" | |
| 162 | + | if bad { | |
| 163 | + | kind = "bad-url" | |
| 164 | + | } | |
| 165 | + | emit(kind, s, text, l, c) | |
| 166 | + | continue | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | emit(k, s, value, l, c) | |
| 170 | + | continue | |
| 171 | + | } | |
| 172 | + | s := take(n) | |
| 173 | + | emit(s, s, s, l, c) | |
| 174 | + | } | |
| 175 | + | return out | |
| 176 | + | } | |
| 177 | + | func identStart(s string) bool { | |
| 178 | + | if len(s) == 0 { | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | r, _ := utf8.DecodeRuneInString(s) | |
| 182 | + | return unicode.IsLetter(r) || r == '_' || r == '-' || r == '\\' || r >= 128 | |
| 183 | + | } | |
| 184 | + | func identLen(s string) int { | |
| 185 | + | i := 0 | |
| 186 | + | for i < len(s) { | |
| 187 | + | r, n := utf8.DecodeRuneInString(s[i:]) | |
| 188 | + | if r == '\\' { | |
| 189 | + | i += n | |
| 190 | + | if i < len(s) { | |
| 191 | + | _, e := escape(s[i:]) | |
| 192 | + | i += e | |
| 193 | + | } | |
| 194 | + | continue | |
| 195 | + | } | |
| 196 | + | if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' && r < 128 { | |
| 197 | + | break | |
| 198 | + | } | |
| 199 | + | i += n | |
| 200 | + | } | |
| 201 | + | return i | |
| 202 | + | } | |
| 203 | + | func escape(s string) (rune, int) { | |
| 204 | + | if len(s) == 0 { | |
| 205 | + | return '\uFFFD', 0 | |
| 206 | + | } | |
| 207 | + | i := 0 | |
| 208 | + | var x rune | |
| 209 | + | for i < len(s) && i < 6 { | |
| 210 | + | ch := s[i] | |
| 211 | + | var d byte | |
| 212 | + | switch { | |
| 213 | + | case ch >= '0' && ch <= '9': | |
| 214 | + | d = ch - '0' | |
| 215 | + | case ch >= 'a' && ch <= 'f': | |
| 216 | + | d = ch - 'a' + 10 | |
| 217 | + | case ch >= 'A' && ch <= 'F': | |
| 218 | + | d = ch - 'A' + 10 | |
| 219 | + | default: | |
| 220 | + | goto done | |
| 221 | + | } | |
| 222 | + | x = x*16 + rune(d) | |
| 223 | + | i++ | |
| 224 | + | } | |
| 225 | + | done: | |
| 226 | + | if i > 0 { | |
| 227 | + | if i < len(s) && s[i] == ' ' { | |
| 228 | + | i++ | |
| 229 | + | } | |
| 230 | + | if x == 0 || x > utf8.MaxRune || x >= 0xd800 && x <= 0xdfff { | |
| 231 | + | x = '\uFFFD' | |
| 232 | + | } | |
| 233 | + | return x, i | |
| 234 | + | } | |
| 235 | + | r, n := utf8.DecodeRuneInString(s) | |
| 236 | + | return r, n | |
| 237 | + | } | |
| 238 | + | func numberStart(s string) bool { | |
| 239 | + | if len(s) == 0 { | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | i := 0 | |
| 243 | + | if s[i] == '+' || s[i] == '-' { | |
| 244 | + | i++ | |
| 245 | + | } | |
| 246 | + | if i < len(s) && s[i] >= '0' && s[i] <= '9' { | |
| 247 | + | return true | |
| 248 | + | } | |
| 249 | + | return i+1 < len(s) && s[i] == '.' && s[i+1] >= '0' && s[i+1] <= '9' | |
| 250 | + | } | |
| 251 | + | func numberLen(s string) int { | |
| 252 | + | i := 0 | |
| 253 | + | if s[i] == '+' || s[i] == '-' { | |
| 254 | + | i++ | |
| 255 | + | } | |
| 256 | + | for i < len(s) && s[i] >= '0' && s[i] <= '9' { | |
| 257 | + | i++ | |
| 258 | + | } | |
| 259 | + | if i < len(s) && s[i] == '.' { | |
| 260 | + | i++ | |
| 261 | + | for i < len(s) && s[i] >= '0' && s[i] <= '9' { | |
| 262 | + | i++ | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | if i < len(s) && (s[i] == 'e' || s[i] == 'E') { | |
| 266 | + | j := i + 1 | |
| 267 | + | if j < len(s) && (s[j] == '+' || s[j] == '-') { | |
| 268 | + | j++ | |
| 269 | + | } | |
| 270 | + | k := j | |
| 271 | + | for j < len(s) && s[j] >= '0' && s[j] <= '9' { | |
| 272 | + | j++ | |
| 273 | + | } | |
| 274 | + | if j > k { | |
| 275 | + | i = j | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | return i | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func unescapeIdent(s string) string { | |
| 282 | + | var b strings.Builder | |
| 283 | + | for i := 0; i < len(s); { | |
| 284 | + | if s[i] == '\\' && i+1 < len(s) { | |
| 285 | + | r, n := escape(s[i+1:]) | |
| 286 | + | b.WriteRune(r) | |
| 287 | + | i += n + 1 | |
| 288 | + | } else { | |
| 289 | + | r, n := utf8.DecodeRuneInString(s[i:]) | |
| 290 | + | b.WriteRune(r) | |
| 291 | + | i += n | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | return b.String() | |
| 295 | + | } | |
A internal/css/ua.css +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | /* NeferGUI UA defaults: stack overlay behavior is interpreted by layout from its type. */ | |
| 2 | + | row { display: flex; flex-direction: row; } | |
| 3 | + | column { display: flex; flex-direction: column; } | |
| 4 | + | stack { display: block; } | |
| 5 | + | /* Additive semantic defaults; author CSS may override every declaration. */ | |
| 6 | + | radio, slider, separator, spacer, icon { display: block; } | |
| 7 | + | button { text-align: center; } | |
| 8 | + | button, checkbox { cursor: pointer; } | |
| 9 | + | /* Room for the painted indicator (one em) and the slider track. */ | |
| 10 | + | checkbox, radio { padding-left: 1.5em; } | |
| 11 | + | slider { padding-bottom: 1.25em; } | |
| 12 | + | input, textarea { cursor: text; } | |
| 13 | + | button:disabled, checkbox:disabled, input:disabled, textarea:disabled { cursor: not-allowed; } | |
| 14 | + | radio { min-height: 18px; cursor: pointer; } | |
| 15 | + | slider { min-height: 18px; cursor: pointer; } | |
| 16 | + | separator { height: 1px; background: #808080; } | |
| 17 | + | spacer { min-height: 8px; } | |
| 18 | + | icon { min-width: 16px; min-height: 16px; } | |
| 19 | + | checkbox:disabled, radio:disabled, slider:disabled { opacity: 0.5; cursor: not-allowed; } | |
A internal/css/value.go +664 -0
| @@ -0,0 +1,664 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type Length struct { | |
| 10 | + | Value float64 | |
| 11 | + | Unit string | |
| 12 | + | } | |
| 13 | + | type Color struct{ R, G, B, A float64 } | |
| 14 | + | type Shadow struct { | |
| 15 | + | Inset bool | |
| 16 | + | X, Y, Blur, Spread Length | |
| 17 | + | Color Color | |
| 18 | + | } | |
| 19 | + | type Value struct { | |
| 20 | + | Length Length | |
| 21 | + | Color Color | |
| 22 | + | Number float64 | |
| 23 | + | Keyword string | |
| 24 | + | Families []string | |
| 25 | + | Shadows []Shadow | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func finite(s string) (float64, bool) { | |
| 29 | + | v, e := strconv.ParseFloat(s, 64) | |
| 30 | + | return v, e == nil && !math.IsNaN(v) && !math.IsInf(v, 0) | |
| 31 | + | } | |
| 32 | + | func length(s string, negative, auto bool) (Length, bool) { | |
| 33 | + | s = strings.TrimSpace(strings.ToLower(s)) | |
| 34 | + | if auto && s == "auto" { | |
| 35 | + | return Length{Unit: "auto"}, true | |
| 36 | + | } | |
| 37 | + | for _, unit := range []string{"rem", "em", "px", "%"} { | |
| 38 | + | if strings.HasSuffix(s, unit) { | |
| 39 | + | v, ok := finite(strings.TrimSuffix(s, unit)) | |
| 40 | + | return Length{v, unit}, ok && (negative || v >= 0) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | v, ok := finite(s) | |
| 44 | + | return Length{v, "px"}, ok && v == 0 | |
| 45 | + | } | |
| 46 | + | func resolveLen(l Length, font, root float64) Length { | |
| 47 | + | switch l.Unit { | |
| 48 | + | case "em": | |
| 49 | + | l.Value *= font | |
| 50 | + | l.Unit = "px" | |
| 51 | + | case "rem": | |
| 52 | + | l.Value *= root | |
| 53 | + | l.Unit = "px" | |
| 54 | + | } | |
| 55 | + | return l | |
| 56 | + | } | |
| 57 | + | func validLength(l Length) bool { return !math.IsNaN(l.Value) && !math.IsInf(l.Value, 0) } | |
| 58 | + | func rgb(r, g, b, a float64) Color { return Color{clamp(r), clamp(g), clamp(b), clamp(a)} } | |
| 59 | + | func clamp(x float64) float64 { return math.Max(0, math.Min(1, x)) } | |
| 60 | + | func parseColor(s string, current Color) (Color, bool) { | |
| 61 | + | s = strings.ToLower(strings.TrimSpace(s)) | |
| 62 | + | if s == "currentcolor" { | |
| 63 | + | return current, true | |
| 64 | + | } | |
| 65 | + | if s == "transparent" { | |
| 66 | + | return Color{}, true | |
| 67 | + | } | |
| 68 | + | if strings.HasPrefix(s, "#") { | |
| 69 | + | h := s[1:] | |
| 70 | + | if len(h) != 3 && len(h) != 4 && len(h) != 6 && len(h) != 8 { | |
| 71 | + | return Color{}, false | |
| 72 | + | } | |
| 73 | + | var b [4]uint64 | |
| 74 | + | b[3] = 255 | |
| 75 | + | short := len(h) <= 4 | |
| 76 | + | n := 3 | |
| 77 | + | if len(h) == 4 || len(h) == 8 { | |
| 78 | + | n = 4 | |
| 79 | + | } | |
| 80 | + | for i := 0; i < n; i++ { | |
| 81 | + | part := "" | |
| 82 | + | if short { | |
| 83 | + | part = strings.Repeat(string(h[i]), 2) | |
| 84 | + | } else { | |
| 85 | + | part = h[i*2 : i*2+2] | |
| 86 | + | } | |
| 87 | + | v, e := strconv.ParseUint(part, 16, 8) | |
| 88 | + | if e != nil { | |
| 89 | + | return Color{}, false | |
| 90 | + | } | |
| 91 | + | b[i] = v | |
| 92 | + | } | |
| 93 | + | return rgb(float64(b[0])/255, float64(b[1])/255, float64(b[2])/255, float64(b[3])/255), true | |
| 94 | + | } | |
| 95 | + | open := strings.IndexByte(s, '(') | |
| 96 | + | if open < 0 || !strings.HasSuffix(s, ")") { | |
| 97 | + | if v, ok := namedColors[s]; ok { | |
| 98 | + | return v, true | |
| 99 | + | } | |
| 100 | + | return Color{}, false | |
| 101 | + | } | |
| 102 | + | fn := s[:open] | |
| 103 | + | if fn != "rgb" && fn != "rgba" && fn != "hsl" && fn != "hsla" { | |
| 104 | + | return Color{}, false | |
| 105 | + | } | |
| 106 | + | inner := strings.TrimSpace(s[open+1 : len(s)-1]) | |
| 107 | + | comma := strings.Contains(inner, ",") | |
| 108 | + | var args []string | |
| 109 | + | if comma { | |
| 110 | + | args = strings.Split(inner, ",") | |
| 111 | + | } else { | |
| 112 | + | inner = strings.ReplaceAll(inner, "/", " / ") | |
| 113 | + | args = strings.Fields(inner) | |
| 114 | + | } | |
| 115 | + | for i := range args { | |
| 116 | + | args[i] = strings.TrimSpace(args[i]) | |
| 117 | + | } | |
| 118 | + | if !comma && len(args) == 5 && args[3] == "/" { | |
| 119 | + | args = append(args[:3], args[4]) | |
| 120 | + | } | |
| 121 | + | if len(args) < 3 || len(args) > 4 || comma && strings.Contains(inner, "/") { | |
| 122 | + | return Color{}, false | |
| 123 | + | } | |
| 124 | + | if (fn == "rgba" || fn == "hsla") && len(args) != 4 { | |
| 125 | + | return Color{}, false | |
| 126 | + | } | |
| 127 | + | a := 1.0 | |
| 128 | + | if len(args) == 4 { | |
| 129 | + | v, ok := channel(args[3], 1) | |
| 130 | + | if !ok { | |
| 131 | + | return Color{}, false | |
| 132 | + | } | |
| 133 | + | a = v | |
| 134 | + | } | |
| 135 | + | if fn == "rgb" || fn == "rgba" { | |
| 136 | + | var ch [3]float64 | |
| 137 | + | for i := 0; i < 3; i++ { | |
| 138 | + | v, ok := channel(args[i], 255) | |
| 139 | + | if !ok { | |
| 140 | + | return Color{}, false | |
| 141 | + | } | |
| 142 | + | ch[i] = v | |
| 143 | + | } | |
| 144 | + | return rgb(ch[0], ch[1], ch[2], a), true | |
| 145 | + | } | |
| 146 | + | hue := strings.TrimSuffix(args[0], "deg") | |
| 147 | + | h, ok := finite(hue) | |
| 148 | + | if !ok { | |
| 149 | + | return Color{}, false | |
| 150 | + | } | |
| 151 | + | sat, ok := percent(args[1]) | |
| 152 | + | if !ok { | |
| 153 | + | return Color{}, false | |
| 154 | + | } | |
| 155 | + | light, ok := percent(args[2]) | |
| 156 | + | if !ok { | |
| 157 | + | return Color{}, false | |
| 158 | + | } | |
| 159 | + | h = math.Mod(h, 360) | |
| 160 | + | if h < 0 { | |
| 161 | + | h += 360 | |
| 162 | + | } | |
| 163 | + | sat = clamp(sat) | |
| 164 | + | light = clamp(light) | |
| 165 | + | c := (1 - math.Abs(2*light-1)) * sat | |
| 166 | + | x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) | |
| 167 | + | m := light - c/2 | |
| 168 | + | var r, g, b float64 | |
| 169 | + | switch { | |
| 170 | + | case h < 60: | |
| 171 | + | r, g = c, x | |
| 172 | + | case h < 120: | |
| 173 | + | r, g = x, c | |
| 174 | + | case h < 180: | |
| 175 | + | g, b = c, x | |
| 176 | + | case h < 240: | |
| 177 | + | g, b = x, c | |
| 178 | + | case h < 300: | |
| 179 | + | r, b = x, c | |
| 180 | + | default: | |
| 181 | + | r, b = c, x | |
| 182 | + | } | |
| 183 | + | return rgb(r+m, g+m, b+m, a), true | |
| 184 | + | } | |
| 185 | + | func percent(s string) (float64, bool) { | |
| 186 | + | if !strings.HasSuffix(s, "%") { | |
| 187 | + | return 0, false | |
| 188 | + | } | |
| 189 | + | v, ok := finite(strings.TrimSuffix(s, "%")) | |
| 190 | + | return v / 100, ok | |
| 191 | + | } | |
| 192 | + | func channel(s string, scale float64) (float64, bool) { | |
| 193 | + | if strings.HasSuffix(s, "%") { | |
| 194 | + | return percent(s) | |
| 195 | + | } | |
| 196 | + | v, ok := finite(s) | |
| 197 | + | return v / scale, ok | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // splitTopLevel separates commas only outside functions and quoted strings. | |
| 201 | + | func splitTopLevel(s string) []string { | |
| 202 | + | var parts []string | |
| 203 | + | start, depth := 0, 0 | |
| 204 | + | pos := 0 | |
| 205 | + | for _, t := range Tokenize(s) { | |
| 206 | + | if t.Kind == "," && depth == 0 { | |
| 207 | + | parts = append(parts, s[start:pos]) | |
| 208 | + | start = pos + len(t.Raw) | |
| 209 | + | } | |
| 210 | + | if t.Kind == "function" || t.Kind == "(" { | |
| 211 | + | depth++ | |
| 212 | + | } | |
| 213 | + | if t.Kind == ")" { | |
| 214 | + | depth-- | |
| 215 | + | } | |
| 216 | + | pos += len(t.Raw) | |
| 217 | + | } | |
| 218 | + | parts = append(parts, s[start:]) | |
| 219 | + | return parts | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func fields(s string) []string { | |
| 223 | + | ts := Tokenize(s) | |
| 224 | + | var parts []string | |
| 225 | + | var buf strings.Builder | |
| 226 | + | depth := 0 | |
| 227 | + | for _, t := range ts { | |
| 228 | + | if t.Kind == "function" { | |
| 229 | + | depth++ | |
| 230 | + | } | |
| 231 | + | if t.Kind == ")" { | |
| 232 | + | depth-- | |
| 233 | + | } | |
| 234 | + | if t.Kind == "space" && depth == 0 { | |
| 235 | + | if buf.Len() > 0 { | |
| 236 | + | parts = append(parts, buf.String()) | |
| 237 | + | buf.Reset() | |
| 238 | + | } | |
| 239 | + | continue | |
| 240 | + | } | |
| 241 | + | buf.WriteString(t.Raw) | |
| 242 | + | } | |
| 243 | + | if buf.Len() > 0 { | |
| 244 | + | parts = append(parts, buf.String()) | |
| 245 | + | } | |
| 246 | + | return parts | |
| 247 | + | } | |
| 248 | + | func sides(xs []string) ([]string, bool) { | |
| 249 | + | if len(xs) < 1 || len(xs) > 4 { | |
| 250 | + | return nil, false | |
| 251 | + | } | |
| 252 | + | switch len(xs) { | |
| 253 | + | case 1: | |
| 254 | + | return []string{xs[0], xs[0], xs[0], xs[0]}, true | |
| 255 | + | case 2: | |
| 256 | + | return []string{xs[0], xs[1], xs[0], xs[1]}, true | |
| 257 | + | case 3: | |
| 258 | + | return []string{xs[0], xs[1], xs[2], xs[1]}, true | |
| 259 | + | default: | |
| 260 | + | return xs, true | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | var sideNames = []string{"top", "right", "bottom", "left"} | |
| 265 | + | var cornerNames = []string{"top-left", "top-right", "bottom-right", "bottom-left"} | |
| 266 | + | ||
| 267 | + | func expand(d Declaration) ([]Declaration, bool) { | |
| 268 | + | n := d.Property | |
| 269 | + | makeD := func(name, value string) Declaration { return Declaration{name, value, d.Important, d.Line, d.Col} } | |
| 270 | + | v := d.Value | |
| 271 | + | if n == "background" { | |
| 272 | + | return []Declaration{makeD("background-color", v)}, true | |
| 273 | + | } | |
| 274 | + | for _, prefix := range []string{"margin", "padding", "border-width", "border-style", "border-color", "border-radius"} { | |
| 275 | + | if n == prefix { | |
| 276 | + | xs, ok := sides(fields(v)) | |
| 277 | + | if !ok { | |
| 278 | + | return nil, false | |
| 279 | + | } | |
| 280 | + | var ds []Declaration | |
| 281 | + | for i, x := range xs { | |
| 282 | + | name := prefix + "-" + sideNames[i] | |
| 283 | + | if prefix == "border-width" || prefix == "border-style" || prefix == "border-color" { | |
| 284 | + | name = "border-" + sideNames[i] + "-" + strings.TrimPrefix(prefix, "border-") | |
| 285 | + | } | |
| 286 | + | if prefix == "border-radius" { | |
| 287 | + | name = "border-" + cornerNames[i] + "-radius" | |
| 288 | + | } | |
| 289 | + | ds = append(ds, makeD(name, x)) | |
| 290 | + | } | |
| 291 | + | return ds, true | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | if n == "gap" { | |
| 295 | + | xs := fields(v) | |
| 296 | + | if len(xs) < 1 || len(xs) > 2 { | |
| 297 | + | return nil, false | |
| 298 | + | } | |
| 299 | + | if len(xs) == 1 { | |
| 300 | + | xs = append(xs, xs[0]) | |
| 301 | + | } | |
| 302 | + | return []Declaration{makeD("row-gap", xs[0]), makeD("column-gap", xs[1])}, true | |
| 303 | + | } | |
| 304 | + | if n == "overflow" { | |
| 305 | + | xs := fields(v) | |
| 306 | + | if len(xs) < 1 || len(xs) > 2 { | |
| 307 | + | return nil, false | |
| 308 | + | } | |
| 309 | + | if len(xs) == 1 { | |
| 310 | + | xs = append(xs, xs[0]) | |
| 311 | + | } | |
| 312 | + | return []Declaration{makeD("overflow-x", xs[0]), makeD("overflow-y", xs[1])}, true | |
| 313 | + | } | |
| 314 | + | if n == "flex" { | |
| 315 | + | xs := fields(v) | |
| 316 | + | if v == "none" { | |
| 317 | + | xs = []string{"0", "0", "auto"} | |
| 318 | + | } else if v == "auto" { | |
| 319 | + | xs = []string{"1", "1", "auto"} | |
| 320 | + | } else { | |
| 321 | + | if len(xs) < 1 || len(xs) > 3 { | |
| 322 | + | return nil, false | |
| 323 | + | } | |
| 324 | + | if _, ok := finite(xs[0]); !ok { | |
| 325 | + | return nil, false | |
| 326 | + | } | |
| 327 | + | if len(xs) == 1 { | |
| 328 | + | xs = append(xs, "1", "0%") | |
| 329 | + | } else if len(xs) == 2 { | |
| 330 | + | if _, ok := finite(xs[1]); ok { | |
| 331 | + | xs = append(xs, "0%") | |
| 332 | + | } else { | |
| 333 | + | xs = []string{xs[0], "1", xs[1]} | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | return []Declaration{makeD("flex-grow", xs[0]), makeD("flex-shrink", xs[1]), makeD("flex-basis", xs[2])}, true | |
| 338 | + | } | |
| 339 | + | if n == "border" || n == "outline" || strings.HasPrefix(n, "border-") && (n == "border-top" || n == "border-right" || n == "border-bottom" || n == "border-left") { | |
| 340 | + | xs := fields(v) | |
| 341 | + | width, style, color := "0", "none", "currentColor" | |
| 342 | + | used := [3]bool{} | |
| 343 | + | for _, x := range xs { | |
| 344 | + | if _, ok := length(x, false, false); ok && !used[0] { | |
| 345 | + | width = x | |
| 346 | + | used[0] = true | |
| 347 | + | continue | |
| 348 | + | } | |
| 349 | + | if (x == "solid" || x == "none") && !used[1] { | |
| 350 | + | style = x | |
| 351 | + | used[1] = true | |
| 352 | + | continue | |
| 353 | + | } | |
| 354 | + | if _, ok := parseColor(x, Color{}); ok && !used[2] { | |
| 355 | + | color = x | |
| 356 | + | used[2] = true | |
| 357 | + | continue | |
| 358 | + | } | |
| 359 | + | return nil, false | |
| 360 | + | } | |
| 361 | + | if len(xs) == 0 { | |
| 362 | + | return nil, false | |
| 363 | + | } | |
| 364 | + | targets := []string{n} | |
| 365 | + | if n == "border" { | |
| 366 | + | targets = []string{"border-top", "border-right", "border-bottom", "border-left"} | |
| 367 | + | } | |
| 368 | + | var ds []Declaration | |
| 369 | + | for _, t := range targets { | |
| 370 | + | ds = append(ds, makeD(t+"-width", width), makeD(t+"-style", style), makeD(t+"-color", color)) | |
| 371 | + | } | |
| 372 | + | return ds, true | |
| 373 | + | } | |
| 374 | + | return []Declaration{d}, true | |
| 375 | + | } | |
| 376 | + | func inherited(name string) bool { | |
| 377 | + | switch name { | |
| 378 | + | case "color", "font-family", "font-size", "font-weight", "font-style", "line-height", "text-align", "letter-spacing", "cursor", "accent-color": | |
| 379 | + | return true | |
| 380 | + | } | |
| 381 | + | return false | |
| 382 | + | } | |
| 383 | + | func initial(name string) string { | |
| 384 | + | switch name { | |
| 385 | + | case "color": | |
| 386 | + | return "black" | |
| 387 | + | case "accent-color": | |
| 388 | + | return "#3584e4" | |
| 389 | + | case "opacity", "flex-shrink": | |
| 390 | + | return "1" | |
| 391 | + | case "background-color": | |
| 392 | + | return "transparent" | |
| 393 | + | case "box-shadow": | |
| 394 | + | return "none" | |
| 395 | + | case "width", "height", "min-width", "min-height", "max-width", "max-height", "flex-basis", "align-self", "cursor": | |
| 396 | + | return "auto" | |
| 397 | + | case "box-sizing": | |
| 398 | + | return "content-box" | |
| 399 | + | case "display": | |
| 400 | + | return "block" | |
| 401 | + | case "flex-direction": | |
| 402 | + | return "row" | |
| 403 | + | case "flex-wrap": | |
| 404 | + | return "nowrap" | |
| 405 | + | case "justify-content": | |
| 406 | + | return "flex-start" | |
| 407 | + | case "align-items": | |
| 408 | + | return "stretch" | |
| 409 | + | case "overflow-x", "overflow-y": | |
| 410 | + | return "visible" | |
| 411 | + | case "font-family": | |
| 412 | + | return "sans-serif" | |
| 413 | + | case "font-size": | |
| 414 | + | return "16px" | |
| 415 | + | case "font-weight": | |
| 416 | + | return "400" | |
| 417 | + | case "font-style", "letter-spacing", "line-height": | |
| 418 | + | return "normal" | |
| 419 | + | case "text-align": | |
| 420 | + | return "start" | |
| 421 | + | } | |
| 422 | + | if strings.HasSuffix(name, "-color") { | |
| 423 | + | return "currentColor" | |
| 424 | + | } | |
| 425 | + | if strings.HasSuffix(name, "-style") { | |
| 426 | + | return "none" | |
| 427 | + | } | |
| 428 | + | return "0" | |
| 429 | + | } | |
| 430 | + | func parseValue(name, s string, current Color, font, root float64) (Value, bool) { | |
| 431 | + | var out Value | |
| 432 | + | s = strings.TrimSpace(s) | |
| 433 | + | lower := strings.ToLower(s) | |
| 434 | + | if name == "color" || name == "background-color" || strings.HasSuffix(name, "-color") { | |
| 435 | + | var ok bool | |
| 436 | + | out.Color, ok = parseColor(s, current) | |
| 437 | + | return out, ok | |
| 438 | + | } | |
| 439 | + | switch name { | |
| 440 | + | case "opacity", "flex-grow", "flex-shrink": | |
| 441 | + | v, ok := finite(s) | |
| 442 | + | if !ok || v < 0 || name == "opacity" && v > 1 { | |
| 443 | + | return out, false | |
| 444 | + | } | |
| 445 | + | out.Number = v | |
| 446 | + | return out, true | |
| 447 | + | case "font-weight": | |
| 448 | + | if lower == "normal" { | |
| 449 | + | out.Number = 400 | |
| 450 | + | return out, true | |
| 451 | + | } | |
| 452 | + | if lower == "bold" { | |
| 453 | + | out.Number = 700 | |
| 454 | + | return out, true | |
| 455 | + | } | |
| 456 | + | v, ok := finite(s) | |
| 457 | + | if ok && v >= 100 && v <= 900 && math.Mod(v, 100) == 0 { | |
| 458 | + | out.Number = v | |
| 459 | + | return out, true | |
| 460 | + | } | |
| 461 | + | return out, false | |
| 462 | + | case "font-family": | |
| 463 | + | for _, p := range strings.Split(s, ",") { | |
| 464 | + | p = strings.TrimSpace(p) | |
| 465 | + | if p == "" { | |
| 466 | + | return out, false | |
| 467 | + | } | |
| 468 | + | if strings.HasPrefix(p, "\"") || strings.HasPrefix(p, "'") { | |
| 469 | + | ts := Tokenize(p) | |
| 470 | + | if len(ts) != 1 || ts[0].Kind != "string" { | |
| 471 | + | return out, false | |
| 472 | + | } | |
| 473 | + | p = ts[0].Value | |
| 474 | + | } | |
| 475 | + | out.Families = append(out.Families, p) | |
| 476 | + | } | |
| 477 | + | return out, len(out.Families) > 0 | |
| 478 | + | case "box-shadow": | |
| 479 | + | if lower == "none" { | |
| 480 | + | return out, true | |
| 481 | + | } | |
| 482 | + | for _, part := range splitTopLevel(s) { | |
| 483 | + | xs := fields(part) | |
| 484 | + | sh := Shadow{Color: current} | |
| 485 | + | var ls []Length | |
| 486 | + | for _, x := range xs { | |
| 487 | + | if x == "inset" && !sh.Inset { | |
| 488 | + | sh.Inset = true | |
| 489 | + | continue | |
| 490 | + | } | |
| 491 | + | if l, ok := length(x, true, false); ok { | |
| 492 | + | ls = append(ls, resolveLen(l, font, root)) | |
| 493 | + | continue | |
| 494 | + | } | |
| 495 | + | if c, ok := parseColor(x, current); ok { | |
| 496 | + | sh.Color = c | |
| 497 | + | continue | |
| 498 | + | } | |
| 499 | + | return out, false | |
| 500 | + | } | |
| 501 | + | if len(ls) < 2 || len(ls) > 4 { | |
| 502 | + | return out, false | |
| 503 | + | } | |
| 504 | + | for _, l := range ls { | |
| 505 | + | if !validLength(l) { | |
| 506 | + | return out, false | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | sh.X, sh.Y = ls[0], ls[1] | |
| 510 | + | if len(ls) > 2 { | |
| 511 | + | sh.Blur = ls[2] | |
| 512 | + | if sh.Blur.Value < 0 { | |
| 513 | + | return out, false | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | if len(ls) > 3 { | |
| 517 | + | sh.Spread = ls[3] | |
| 518 | + | } | |
| 519 | + | out.Shadows = append(out.Shadows, sh) | |
| 520 | + | } | |
| 521 | + | return out, true | |
| 522 | + | case "font-size": | |
| 523 | + | l, ok := length(s, false, false) | |
| 524 | + | if !ok { | |
| 525 | + | return out, false | |
| 526 | + | } | |
| 527 | + | switch l.Unit { | |
| 528 | + | case "%": | |
| 529 | + | l.Value = font * l.Value / 100 | |
| 530 | + | l.Unit = "px" | |
| 531 | + | case "em": | |
| 532 | + | l.Value *= font | |
| 533 | + | l.Unit = "px" | |
| 534 | + | case "rem": | |
| 535 | + | l.Value *= root | |
| 536 | + | l.Unit = "px" | |
| 537 | + | } | |
| 538 | + | out.Length = l | |
| 539 | + | return out, validLength(l) | |
| 540 | + | case "line-height": | |
| 541 | + | if lower == "normal" { | |
| 542 | + | out.Keyword = lower | |
| 543 | + | return out, true | |
| 544 | + | } | |
| 545 | + | if v, ok := finite(s); ok && v >= 0 { | |
| 546 | + | out.Number = v | |
| 547 | + | out.Keyword = "number" | |
| 548 | + | return out, true | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | lengthName := false | |
| 552 | + | negative := false | |
| 553 | + | auto := false | |
| 554 | + | switch { | |
| 555 | + | case name == "width" || name == "height" || name == "min-width" || name == "min-height" || name == "max-width" || name == "max-height" || name == "flex-basis": | |
| 556 | + | lengthName = true | |
| 557 | + | auto = true | |
| 558 | + | case name == "outline-offset" || name == "letter-spacing" || strings.HasPrefix(name, "margin-"): | |
| 559 | + | lengthName = true | |
| 560 | + | negative = true | |
| 561 | + | auto = strings.HasPrefix(name, "margin-") | |
| 562 | + | case name == "font-size" || name == "line-height" || name == "row-gap" || name == "column-gap" || strings.HasPrefix(name, "padding-") || strings.HasSuffix(name, "-width") || strings.HasSuffix(name, "-radius"): | |
| 563 | + | lengthName = true | |
| 564 | + | } | |
| 565 | + | if name == "letter-spacing" && lower == "normal" { | |
| 566 | + | out.Keyword = lower | |
| 567 | + | return out, true | |
| 568 | + | } | |
| 569 | + | if lengthName { | |
| 570 | + | l, ok := length(s, negative, auto) | |
| 571 | + | if name == "outline-offset" || name == "letter-spacing" { | |
| 572 | + | ok = ok && l.Unit != "%" | |
| 573 | + | } | |
| 574 | + | out.Length = resolveLen(l, font, root) | |
| 575 | + | return out, ok && validLength(out.Length) | |
| 576 | + | } | |
| 577 | + | valid := map[string]string{"box-sizing": "content-box border-box", "display": "flex block none", "flex-direction": "row column", "flex-wrap": "nowrap wrap", "justify-content": "flex-start flex-end center space-between space-around space-evenly", "align-items": "stretch flex-start flex-end center", "align-self": "auto stretch flex-start flex-end center", "overflow-x": "visible hidden scroll auto", "overflow-y": "visible hidden scroll auto", "font-style": "normal italic oblique", "text-align": "start end left right center", "cursor": "auto default pointer text not-allowed"} | |
| 578 | + | if name == "outline-style" || strings.HasPrefix(name, "border-") && strings.HasSuffix(name, "-style") { | |
| 579 | + | valid[name] = "none solid" | |
| 580 | + | } | |
| 581 | + | for _, v := range strings.Fields(valid[name]) { | |
| 582 | + | if lower == v { | |
| 583 | + | out.Keyword = v | |
| 584 | + | return out, true | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | return out, false | |
| 588 | + | } | |
| 589 | + | func containsVar(s string) bool { | |
| 590 | + | for _, t := range Tokenize(s) { | |
| 591 | + | if t.Kind == "function" && strings.EqualFold(t.Value, "var") { | |
| 592 | + | return true | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | return false | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | func resolveVars(s string, custom map[string]string, active map[string]bool, depth int) (string, bool) { | |
| 599 | + | if strings.Contains(s, "/*") { | |
| 600 | + | s = raw(Tokenize(s)) | |
| 601 | + | } | |
| 602 | + | if depth > 64 { | |
| 603 | + | return "", false | |
| 604 | + | } | |
| 605 | + | var out strings.Builder | |
| 606 | + | for { | |
| 607 | + | // Function tokens, unlike byte substrings, cannot occur inside strings or identifiers. | |
| 608 | + | ts := Tokenize(s) | |
| 609 | + | start, end, pos, depth, functionLen := -1, -1, 0, 0, 0 | |
| 610 | + | for _, t := range ts { | |
| 611 | + | if start < 0 { | |
| 612 | + | if t.Kind == "function" && strings.EqualFold(t.Value, "var") { | |
| 613 | + | start = pos + len(t.Raw) | |
| 614 | + | functionLen = len(t.Raw) | |
| 615 | + | } | |
| 616 | + | } else { | |
| 617 | + | if t.Kind == "function" || t.Kind == "(" { | |
| 618 | + | depth++ | |
| 619 | + | } | |
| 620 | + | if t.Kind == ")" { | |
| 621 | + | if depth == 0 { | |
| 622 | + | end = pos | |
| 623 | + | break | |
| 624 | + | } | |
| 625 | + | depth-- | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | pos += len(t.Raw) | |
| 629 | + | } | |
| 630 | + | if start < 0 { | |
| 631 | + | out.WriteString(s) | |
| 632 | + | return out.String(), true | |
| 633 | + | } | |
| 634 | + | if end < 0 { | |
| 635 | + | return "", false | |
| 636 | + | } | |
| 637 | + | out.WriteString(s[:start-functionLen]) | |
| 638 | + | inside := s[start:end] | |
| 639 | + | s = s[end+1:] | |
| 640 | + | parts := splitTopLevel(inside) | |
| 641 | + | if len(parts) > 2 { | |
| 642 | + | parts[1] = strings.Join(parts[1:], ",") | |
| 643 | + | } | |
| 644 | + | name := strings.TrimSpace(parts[0]) | |
| 645 | + | if !strings.HasPrefix(name, "--") { | |
| 646 | + | return "", false | |
| 647 | + | } | |
| 648 | + | v, ok := custom[name] | |
| 649 | + | if ok && !active[name] { | |
| 650 | + | active[name] = true | |
| 651 | + | v, ok = resolveVars(v, custom, active, depth+1) | |
| 652 | + | delete(active, name) | |
| 653 | + | } else { | |
| 654 | + | ok = false | |
| 655 | + | } | |
| 656 | + | if !ok && len(parts) > 1 { | |
| 657 | + | v, ok = resolveVars(strings.TrimSpace(parts[1]), custom, active, depth+1) | |
| 658 | + | } | |
| 659 | + | if !ok { | |
| 660 | + | return "", false | |
| 661 | + | } | |
| 662 | + | out.WriteString(v) | |
| 663 | + | } | |
| 664 | + | } | |
A internal/css/value_block_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package css | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestShadowColorFunctionCommas(t *testing.T) { | |
| 6 | + | v, ok := parseValue("box-shadow", "1px 2px rgb(255,0,0), 3px 4px rgba(0,0,255,.5)", Color{}, 16, 16) | |
| 7 | + | if !ok || len(v.Shadows) != 2 || v.Shadows[0].Color != namedColors["red"] || v.Shadows[1].Color.B != 1 { | |
| 8 | + | t.Fatalf("shadows: %+v %v", v, ok) | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func TestVarInsideQuotedValue(t *testing.T) { | |
| 13 | + | v, ok := resolveVars(`"var(--missing)" var(--x)`, map[string]string{"--x": "red"}, map[string]bool{}, 0) | |
| 14 | + | if !ok || v != `"var(--missing)" red` { | |
| 15 | + | t.Fatalf("got %q %v", v, ok) | |
| 16 | + | } | |
| 17 | + | v, ok = resolveVars(`var(--x, "var(--missing)")`, nil, map[string]bool{}, 0) | |
| 18 | + | if !ok || v != `"var(--missing)"` { | |
| 19 | + | t.Fatalf("fallback got %q %v", v, ok) | |
| 20 | + | } | |
| 21 | + | } | |
A internal/edit/async.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package edit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "unicode/utf8" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // AsyncClipboard starts an asynchronous paste. The callback must be delivered | |
| 9 | + | // to the UI loop, never invoked by a Wayland reader or transport goroutine. | |
| 10 | + | // Unlike Clipboard.ReadText, ReadTextAsync does not wait for another process. | |
| 11 | + | // Implementations must cap reads at limit bytes plus one and honor cancellation. | |
| 12 | + | type AsyncClipboard interface { | |
| 13 | + | ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error | |
| 14 | + | WriteText([]byte) error | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // PasteAsync applies a completed paste on the UI loop. It is the counterpart | |
| 18 | + | // of Paste for asynchronous transports. It retains the same UTF-8/size checks. | |
| 19 | + | func (s *State) PasteAsync(data []byte, err error) (bool, error) { | |
| 20 | + | if err != nil { | |
| 21 | + | return false, err | |
| 22 | + | } | |
| 23 | + | if len(data) > MaxClipboardBytes || !utf8.Valid(data) { | |
| 24 | + | return false, nil | |
| 25 | + | } | |
| 26 | + | return s.Insert(string(data)), nil | |
| 27 | + | } | |
A internal/edit/edit.go +327 -0
| @@ -0,0 +1,327 @@ | |||
| 1 | + | // Package edit owns headless, grapheme-safe text state. Offsets are UTF-8 bytes; | |
| 2 | + | // caret movement is logical, not visual. Rendering may project these offsets | |
| 3 | + | // onto bidi runs to paint discontiguous selection rectangles. | |
| 4 | + | package edit | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "github.com/go-text/typesetting/segmenter" | |
| 8 | + | "strings" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const MaxClipboardBytes = 1 << 20 | |
| 13 | + | ||
| 14 | + | // Clipboard is a bounded text/plain;charset=utf-8 port. Implementations own | |
| 15 | + | // transport, cancellation and file descriptors; the editor never retains one. | |
| 16 | + | type Clipboard interface { | |
| 17 | + | ReadText(limit int) ([]byte, error) | |
| 18 | + | WriteText([]byte) error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // IME is a text-input-v3-shaped output port. All offsets are UTF-8 bytes. | |
| 22 | + | type IME interface { | |
| 23 | + | Enable() | |
| 24 | + | Disable() | |
| 25 | + | Surrounding(text string, cursor, anchor int) | |
| 26 | + | CursorRect(x, y, w, h float64) | |
| 27 | + | ContentType(password, multiline bool) | |
| 28 | + | } | |
| 29 | + | type State struct { | |
| 30 | + | Value string | |
| 31 | + | Cursor, Anchor int | |
| 32 | + | PreferredX float64 | |
| 33 | + | PreferredXValid bool // retained across successive wrapped-line moves | |
| 34 | + | Preedit string | |
| 35 | + | PreeditBegin, PreeditEnd int | |
| 36 | + | active bool | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func Boundaries(s string) []int { | |
| 40 | + | r := []rune(s) | |
| 41 | + | var seg segmenter.Segmenter | |
| 42 | + | seg.Init(r) | |
| 43 | + | it := seg.GraphemeIterator() | |
| 44 | + | b := []int{0} | |
| 45 | + | offset := 0 | |
| 46 | + | for it.Next() { | |
| 47 | + | offset += len(string(it.Grapheme().Text)) | |
| 48 | + | b = append(b, offset) | |
| 49 | + | } | |
| 50 | + | return b | |
| 51 | + | } | |
| 52 | + | func (s *State) Sync(value string) { | |
| 53 | + | if !utf8.ValidString(value) { | |
| 54 | + | value = strings.ToValidUTF8(value, "\uFFFD") | |
| 55 | + | } | |
| 56 | + | s.Value = value | |
| 57 | + | s.Cursor = clamp(value, s.Cursor) | |
| 58 | + | s.Anchor = clamp(value, s.Anchor) | |
| 59 | + | } | |
| 60 | + | func clamp(s string, pos int) int { | |
| 61 | + | b := Boundaries(s) | |
| 62 | + | if pos < 0 { | |
| 63 | + | return 0 | |
| 64 | + | } | |
| 65 | + | last := 0 | |
| 66 | + | for _, v := range b { | |
| 67 | + | if v > pos { | |
| 68 | + | break | |
| 69 | + | } | |
| 70 | + | last = v | |
| 71 | + | } | |
| 72 | + | return last | |
| 73 | + | } | |
| 74 | + | func (s *State) Select(start, end int) { | |
| 75 | + | s.Anchor = clamp(s.Value, start) | |
| 76 | + | s.Cursor = clamp(s.Value, end) | |
| 77 | + | } | |
| 78 | + | func (s *State) Range() (int, int) { | |
| 79 | + | if s.Cursor < s.Anchor { | |
| 80 | + | return s.Cursor, s.Anchor | |
| 81 | + | } | |
| 82 | + | return s.Anchor, s.Cursor | |
| 83 | + | } | |
| 84 | + | func (s *State) Move(to int, shift bool) { | |
| 85 | + | s.Cursor = clamp(s.Value, to) | |
| 86 | + | if !shift { | |
| 87 | + | s.Anchor = s.Cursor | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | func (s *State) Left(shift bool) { | |
| 91 | + | b := Boundaries(s.Value) | |
| 92 | + | p := 0 | |
| 93 | + | for _, v := range b { | |
| 94 | + | if v >= s.Cursor { | |
| 95 | + | break | |
| 96 | + | } | |
| 97 | + | p = v | |
| 98 | + | } | |
| 99 | + | s.Move(p, shift) | |
| 100 | + | } | |
| 101 | + | func (s *State) Right(shift bool) { | |
| 102 | + | for _, v := range Boundaries(s.Value) { | |
| 103 | + | if v > s.Cursor { | |
| 104 | + | s.Move(v, shift) | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | s.Move(len(s.Value), shift) | |
| 109 | + | } | |
| 110 | + | func (s *State) replace(start, end int, text string) bool { | |
| 111 | + | if !utf8.ValidString(text) { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | start = clamp(s.Value, start) | |
| 115 | + | end = clamp(s.Value, end) | |
| 116 | + | if end < start { | |
| 117 | + | start, end = end, start | |
| 118 | + | } | |
| 119 | + | next := s.Value[:start] + text + s.Value[end:] | |
| 120 | + | if next == s.Value { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | s.Value = next | |
| 124 | + | // A newly inserted combining mark can merge into the previous grapheme. | |
| 125 | + | // Round forward to the first boundary at or after the insertion endpoint. | |
| 126 | + | endOfInsert := start + len(text) | |
| 127 | + | for _, boundary := range Boundaries(next) { | |
| 128 | + | if boundary >= endOfInsert { | |
| 129 | + | s.Move(boundary, false) | |
| 130 | + | break | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | func (s *State) Insert(text string) bool { a, b := s.Range(); return s.replace(a, b, text) } | |
| 136 | + | func (s *State) Delete(backward bool, word bool) bool { | |
| 137 | + | a, b := s.Range() | |
| 138 | + | if a == b { | |
| 139 | + | if word { | |
| 140 | + | if backward { | |
| 141 | + | a = s.WordLeft() | |
| 142 | + | } else { | |
| 143 | + | b = s.WordRight() | |
| 144 | + | } | |
| 145 | + | } else { | |
| 146 | + | if backward { | |
| 147 | + | p := *s | |
| 148 | + | p.Left(false) | |
| 149 | + | a = p.Cursor | |
| 150 | + | } else { | |
| 151 | + | p := *s | |
| 152 | + | p.Right(false) | |
| 153 | + | b = p.Cursor | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return s.replace(a, b, "") | |
| 158 | + | } | |
| 159 | + | func (s *State) WordLeft() int { | |
| 160 | + | var seg segmenter.Segmenter | |
| 161 | + | seg.Init([]rune(s.Value)) | |
| 162 | + | it := seg.WordIterator() | |
| 163 | + | p := 0 | |
| 164 | + | for it.Next() { | |
| 165 | + | w := it.Word() | |
| 166 | + | start := len(string([]rune(s.Value)[:w.Offset])) | |
| 167 | + | if start >= s.Cursor { | |
| 168 | + | break | |
| 169 | + | } | |
| 170 | + | p = start | |
| 171 | + | } | |
| 172 | + | return clamp(s.Value, p) | |
| 173 | + | } | |
| 174 | + | func (s *State) WordRight() int { | |
| 175 | + | var seg segmenter.Segmenter | |
| 176 | + | seg.Init([]rune(s.Value)) | |
| 177 | + | it := seg.WordIterator() | |
| 178 | + | for it.Next() { | |
| 179 | + | w := it.Word() | |
| 180 | + | end := len(string([]rune(s.Value)[:w.Offset+len(w.Text)])) | |
| 181 | + | if end > s.Cursor { | |
| 182 | + | return clamp(s.Value, end) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | return len(s.Value) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // WordAt returns exactly the word-iterator segment containing the byte hit. | |
| 189 | + | // On a boundary it prefers the segment to the right; whitespace and punctuation | |
| 190 | + | // are segments in their own right. Offsets and results are grapheme-aligned. | |
| 191 | + | func (s *State) WordAt(offset int) (int, int) { | |
| 192 | + | offset = clamp(s.Value, offset) | |
| 193 | + | runes := []rune(s.Value) | |
| 194 | + | var seg segmenter.Segmenter | |
| 195 | + | seg.Init(runes) | |
| 196 | + | it := seg.WordIterator() | |
| 197 | + | previous := 0 | |
| 198 | + | for it.Next() { | |
| 199 | + | w := it.Word() | |
| 200 | + | start := clamp(s.Value, len(string(runes[:w.Offset]))) | |
| 201 | + | end := clamp(s.Value, len(string(runes[:w.Offset+len(w.Text)]))) | |
| 202 | + | // The iterator skips non-word spans. Preserve those as their own | |
| 203 | + | // segment instead of expanding a hit across adjacent words. | |
| 204 | + | if offset >= previous && offset < start { | |
| 205 | + | return previous, start | |
| 206 | + | } | |
| 207 | + | if offset >= start && offset < end { | |
| 208 | + | return start, end | |
| 209 | + | } | |
| 210 | + | previous = end | |
| 211 | + | } | |
| 212 | + | if offset < len(s.Value) { | |
| 213 | + | return previous, len(s.Value) | |
| 214 | + | } | |
| 215 | + | return len(s.Value), len(s.Value) | |
| 216 | + | } | |
| 217 | + | func (s *State) Mask() string { return strings.Repeat("•", len(Boundaries(s.Value))-1) } | |
| 218 | + | func (s *State) Copy(c Clipboard, password bool) error { | |
| 219 | + | if c == nil || password { | |
| 220 | + | return nil | |
| 221 | + | } | |
| 222 | + | a, b := s.Range() | |
| 223 | + | if b-a > MaxClipboardBytes { | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | return c.WriteText([]byte(s.Value[a:b])) | |
| 227 | + | } | |
| 228 | + | func (s *State) Cut(c Clipboard, password bool) (bool, error) { | |
| 229 | + | if c == nil || password { | |
| 230 | + | return false, nil | |
| 231 | + | } | |
| 232 | + | a, b := s.Range() | |
| 233 | + | if a == b { | |
| 234 | + | return false, nil | |
| 235 | + | } | |
| 236 | + | if err := s.Copy(c, false); err != nil { | |
| 237 | + | return false, err | |
| 238 | + | } | |
| 239 | + | return s.replace(a, b, ""), nil | |
| 240 | + | } | |
| 241 | + | func (s *State) Paste(c Clipboard) (bool, error) { | |
| 242 | + | if c == nil { | |
| 243 | + | return false, nil | |
| 244 | + | } | |
| 245 | + | data, err := c.ReadText(MaxClipboardBytes + 1) | |
| 246 | + | if err != nil { | |
| 247 | + | return false, err | |
| 248 | + | } | |
| 249 | + | if len(data) > MaxClipboardBytes || !utf8.Valid(data) { | |
| 250 | + | return false, nil | |
| 251 | + | } | |
| 252 | + | return s.Insert(string(data)), nil | |
| 253 | + | } | |
| 254 | + | func (s *State) Focus(ime IME, password, multiline bool) { | |
| 255 | + | s.active = ime != nil | |
| 256 | + | if ime != nil { | |
| 257 | + | ime.Enable() | |
| 258 | + | ime.ContentType(password, multiline) | |
| 259 | + | s.Surround(ime, password) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | func (s *State) Blur(ime IME) { | |
| 263 | + | if s.active && ime != nil { | |
| 264 | + | ime.Disable() | |
| 265 | + | } | |
| 266 | + | s.active = false | |
| 267 | + | s.Preedit = "" | |
| 268 | + | s.PreeditBegin = 0 | |
| 269 | + | s.PreeditEnd = 0 | |
| 270 | + | } | |
| 271 | + | func (s *State) Surround(ime IME, password bool) { | |
| 272 | + | if ime == nil { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | if password { | |
| 276 | + | ime.Surrounding("", 0, 0) | |
| 277 | + | } else { | |
| 278 | + | ime.Surrounding(s.Value, s.Cursor, s.Anchor) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | func (s *State) IMEActive() bool { return s.active } | |
| 282 | + | ||
| 283 | + | // IMEBatch is applied atomically on text-input-v3 done. Delete offsets are | |
| 284 | + | // relative to the cursor in bytes, and rounded outwards to grapheme edges. | |
| 285 | + | type IMEBatch struct { | |
| 286 | + | Preedit string | |
| 287 | + | Begin, End int | |
| 288 | + | Commit string | |
| 289 | + | DeleteBefore, DeleteAfter int | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (s *State) Done(b IMEBatch) bool { | |
| 293 | + | changed := false | |
| 294 | + | if b.DeleteBefore > 0 || b.DeleteAfter > 0 { | |
| 295 | + | a := s.Cursor - b.DeleteBefore | |
| 296 | + | if a < 0 { | |
| 297 | + | a = 0 | |
| 298 | + | } | |
| 299 | + | z := s.Cursor + b.DeleteAfter | |
| 300 | + | if z > len(s.Value) { | |
| 301 | + | z = len(s.Value) | |
| 302 | + | } | |
| 303 | + | a = clamp(s.Value, a) | |
| 304 | + | for _, v := range Boundaries(s.Value) { | |
| 305 | + | if v >= z { | |
| 306 | + | z = v | |
| 307 | + | break | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | changed = s.replace(a, z, "") | |
| 311 | + | } | |
| 312 | + | if b.Commit != "" { | |
| 313 | + | changed = s.Insert(b.Commit) || changed | |
| 314 | + | // A commit consumes the outstanding preedit even when no new preedit is sent. | |
| 315 | + | s.Preedit = "" | |
| 316 | + | s.PreeditBegin, s.PreeditEnd = 0, 0 | |
| 317 | + | } | |
| 318 | + | if utf8.ValidString(b.Preedit) { | |
| 319 | + | s.Preedit = b.Preedit | |
| 320 | + | s.PreeditBegin = b.Begin | |
| 321 | + | s.PreeditEnd = b.End | |
| 322 | + | } else { | |
| 323 | + | s.Preedit = "" | |
| 324 | + | s.PreeditBegin, s.PreeditEnd = 0, 0 | |
| 325 | + | } | |
| 326 | + | return changed | |
| 327 | + | } | |
A internal/edit/edit_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package edit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type memoryClipboard struct{ data []byte } | |
| 10 | + | ||
| 11 | + | func (m *memoryClipboard) ReadText(limit int) ([]byte, error) { return m.data, nil } | |
| 12 | + | func (m *memoryClipboard) WriteText(v []byte) error { m.data = append([]byte(nil), v...); return nil } | |
| 13 | + | ||
| 14 | + | type fakeIME struct { | |
| 15 | + | enabled, disabled int | |
| 16 | + | surrounding string | |
| 17 | + | cursor, anchor int | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (f *fakeIME) Enable() { f.enabled++ } | |
| 21 | + | func (f *fakeIME) Disable() { f.disabled++ } | |
| 22 | + | func (f *fakeIME) Surrounding(v string, c, a int) { f.surrounding = v; f.cursor = c; f.anchor = a } | |
| 23 | + | func (f *fakeIME) CursorRect(x, y, w, h float64) {} | |
| 24 | + | func (f *fakeIME) ContentType(password, multiline bool) {} | |
| 25 | + | func TestGraphemesAndIME(t *testing.T) { | |
| 26 | + | s := State{} | |
| 27 | + | s.Sync("e\u0301👩<U+200D>💻a") | |
| 28 | + | s.Move(len(s.Value), false) | |
| 29 | + | s.Left(false) | |
| 30 | + | if s.Cursor != len("e\u0301👩<U+200D>💻") { | |
| 31 | + | t.Fatal(s.Cursor) | |
| 32 | + | } | |
| 33 | + | s.Left(false) | |
| 34 | + | if s.Cursor != len("e\u0301") { | |
| 35 | + | t.Fatal(s.Cursor) | |
| 36 | + | } | |
| 37 | + | s.Select(0, len("e\u0301")) | |
| 38 | + | if !s.Delete(true, false) || s.Value != "👩<U+200D>💻a" { | |
| 39 | + | t.Fatalf("%+v", s) | |
| 40 | + | } | |
| 41 | + | s.Select(len("👩<U+200D>💻"), len("👩<U+200D>💻")) | |
| 42 | + | if !s.Done(IMEBatch{DeleteBefore: 1, Commit: "é"}) || s.Value != "éa" { | |
| 43 | + | t.Fatalf("delete bytes: %+v", s) | |
| 44 | + | } | |
| 45 | + | s.Done(IMEBatch{Preedit: "候補", Begin: 0, End: len("候補")}) | |
| 46 | + | if s.Preedit != "候補" || s.Value != "éa" { | |
| 47 | + | t.Fatal(s) | |
| 48 | + | } | |
| 49 | + | ime := &fakeIME{} | |
| 50 | + | s.Focus(ime, false, false) | |
| 51 | + | s.Blur(ime) | |
| 52 | + | if ime.enabled != 1 || ime.disabled != 1 || s.Preedit != "" { | |
| 53 | + | t.Fatal(s, ime) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | func TestClipboardAndMask(t *testing.T) { | |
| 57 | + | s := State{} | |
| 58 | + | s.Sync("s3crét") | |
| 59 | + | s.Select(0, len(s.Value)) | |
| 60 | + | c := &memoryClipboard{} | |
| 61 | + | if err := s.Copy(c, true); err != nil || len(c.data) != 0 { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if s.Mask() != "••••••" { | |
| 65 | + | t.Fatal(s.Mask()) | |
| 66 | + | } | |
| 67 | + | c.data = []byte{0xff} | |
| 68 | + | if changed, _ := s.Paste(c); changed { | |
| 69 | + | t.Fatal(s) | |
| 70 | + | } | |
| 71 | + | c.data = []byte(strings.Repeat("x", MaxClipboardBytes+1)) | |
| 72 | + | if changed, _ := s.Paste(c); changed { | |
| 73 | + | t.Fatal(s) | |
| 74 | + | } | |
| 75 | + | c.data = []byte("ok") | |
| 76 | + | if changed, _ := s.Paste(c); !changed || s.Value != "ok" { | |
| 77 | + | t.Fatal(s) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | func FuzzEdit(f *testing.F) { | |
| 81 | + | f.Add([]byte("e\u0301👩<U+200D>💻🧪")) | |
| 82 | + | f.Add([]byte{0xff, 0x80, 0x00}) | |
| 83 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 84 | + | if len(data) > 2048 { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | var s State | |
| 88 | + | for _, op := range data { | |
| 89 | + | switch op % 7 { | |
| 90 | + | case 0: | |
| 91 | + | s.Left(false) | |
| 92 | + | case 1: | |
| 93 | + | s.Right(true) | |
| 94 | + | case 2: | |
| 95 | + | s.Insert("e\u0301") | |
| 96 | + | case 3: | |
| 97 | + | s.Insert("👩<U+200D>💻") | |
| 98 | + | case 4: | |
| 99 | + | s.Delete(true, false) | |
| 100 | + | case 5: | |
| 101 | + | s.Delete(false, true) | |
| 102 | + | case 6: | |
| 103 | + | s.Sync(strings.ToValidUTF8(string(data), "�")) | |
| 104 | + | } | |
| 105 | + | if !utf8.ValidString(s.Value) { | |
| 106 | + | t.Fatal("invalid UTF-8") | |
| 107 | + | } | |
| 108 | + | for _, p := range []int{s.Cursor, s.Anchor} { | |
| 109 | + | ok := false | |
| 110 | + | for _, v := range Boundaries(s.Value) { | |
| 111 | + | if p == v { | |
| 112 | + | ok = true | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if !ok { | |
| 116 | + | t.Fatalf("not boundary %d in %q", p, s.Value) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | }) | |
| 121 | + | } | |
A internal/edit/insert_combining_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package edit | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestInsertCombiningMarkLeavesCaretAfterMergedGrapheme(t *testing.T) { | |
| 6 | + | var s State | |
| 7 | + | if !s.Insert("e") || !s.Insert("\u0301") { | |
| 8 | + | t.Fatal("insert failed") | |
| 9 | + | } | |
| 10 | + | if s.Value != "e\u0301" || s.Cursor != len(s.Value) || s.Anchor != s.Cursor { | |
| 11 | + | t.Fatalf("merged grapheme caret: %+v", s) | |
| 12 | + | } | |
| 13 | + | if !s.Insert("x") || s.Value != "e\u0301x" || s.Cursor != len(s.Value) { | |
| 14 | + | t.Fatalf("subsequent insertion: %+v", s) | |
| 15 | + | } | |
| 16 | + | } | |
A internal/edit/word_at_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package edit | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestWordAtSegmentAndRightBoundary(t *testing.T) { | |
| 6 | + | s := State{Value: "one two!"} | |
| 7 | + | for _, tc := range []struct{ offset, start, end int }{{0, 0, 3}, {2, 0, 3}, {3, 3, 4}, {4, 4, 7}, {6, 4, 7}, {7, 7, 8}, {8, 8, 8}} { | |
| 8 | + | a, b := s.WordAt(tc.offset) | |
| 9 | + | if a != tc.start || b != tc.end { | |
| 10 | + | t.Errorf("WordAt(%d)=%d:%d want %d:%d", tc.offset, a, b, tc.start, tc.end) | |
| 11 | + | } | |
| 12 | + | } | |
| 13 | + | s.Sync("é 猫") | |
| 14 | + | if a, b := s.WordAt(len("é ")); a != len("é ") || b != len(s.Value) { | |
| 15 | + | t.Fatalf("UTF-8 boundary %d:%d", a, b) | |
| 16 | + | } | |
| 17 | + | } | |
A internal/harness/assert.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "image" | |
| 6 | + | "image/color" | |
| 7 | + | "image/draw" | |
| 8 | + | "image/png" | |
| 9 | + | "os" | |
| 10 | + | "regexp" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Rect uses half-open pixel coordinates. | |
| 14 | + | type Rect struct { | |
| 15 | + | X int `json:"x"` | |
| 16 | + | Y int `json:"y"` | |
| 17 | + | Width int `json:"width"` | |
| 18 | + | Height int `json:"height"` | |
| 19 | + | } | |
| 20 | + | type Check struct { | |
| 21 | + | Name string `json:"name"` | |
| 22 | + | Pass bool `json:"pass"` | |
| 23 | + | Detail string `json:"detail"` | |
| 24 | + | } | |
| 25 | + | type Probe struct { | |
| 26 | + | Name string `json:"name"` | |
| 27 | + | X int `json:"x"` | |
| 28 | + | Y int `json:"y"` | |
| 29 | + | Color string `json:"color"` | |
| 30 | + | Tolerance uint8 `json:"tolerance"` | |
| 31 | + | } | |
| 32 | + | type Region struct { | |
| 33 | + | Name string `json:"name"` | |
| 34 | + | Rect Rect `json:"rect"` | |
| 35 | + | Color string `json:"color,omitempty"` | |
| 36 | + | Tolerance uint8 `json:"tolerance,omitempty"` | |
| 37 | + | } | |
| 38 | + | type Golden struct { | |
| 39 | + | Name string `json:"name"` | |
| 40 | + | Path string `json:"path"` | |
| 41 | + | Tolerance uint8 `json:"tolerance"` | |
| 42 | + | MaxDiffRatio float64 `json:"max_diff_ratio"` | |
| 43 | + | } | |
| 44 | + | type Expectations struct { | |
| 45 | + | Probes []Probe `json:"probes"` | |
| 46 | + | Regions []Region `json:"regions"` | |
| 47 | + | Goldens []Golden `json:"goldens"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func LoadPNG(path string) (image.Image, error) { | |
| 51 | + | f, err := os.Open(path) | |
| 52 | + | if err != nil { | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | defer f.Close() | |
| 56 | + | return png.Decode(f) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 60 | + | ||
| 61 | + | func ParseColor(s string) (color.NRGBA, error) { | |
| 62 | + | var r, g, b uint8 | |
| 63 | + | if !hexColor.MatchString(s) { | |
| 64 | + | return color.NRGBA{}, fmt.Errorf("color %q: expected #RRGGBB", s) | |
| 65 | + | } | |
| 66 | + | if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err != nil { | |
| 67 | + | return color.NRGBA{}, fmt.Errorf("color %q: %w", s, err) | |
| 68 | + | } | |
| 69 | + | return color.NRGBA{r, g, b, 255}, nil | |
| 70 | + | } | |
| 71 | + | func channels(c color.Color) color.NRGBA { return color.NRGBAModel.Convert(c).(color.NRGBA) } | |
| 72 | + | func closeColor(a, b color.Color, t uint8) bool { | |
| 73 | + | x, y := channels(a), channels(b) | |
| 74 | + | for _, v := range [][2]uint8{{x.R, y.R}, {x.G, y.G}, {x.B, y.B}, {x.A, y.A}} { | |
| 75 | + | d := int(v[0]) - int(v[1]) | |
| 76 | + | if d < 0 { | |
| 77 | + | d = -d | |
| 78 | + | } | |
| 79 | + | if d > int(t) { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | func BoundsCheck(img image.Image, r Rect) error { | |
| 86 | + | b := img.Bounds() | |
| 87 | + | if r.Width <= 0 || r.Height <= 0 || r.X < b.Min.X || r.Y < b.Min.Y || r.X > b.Max.X-r.Width || r.Y > b.Max.Y-r.Height { | |
| 88 | + | return fmt.Errorf("region %+v outside image %v or empty", r, b) | |
| 89 | + | } | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | func PixelProbe(img image.Image, x, y int, want color.Color, t uint8) error { | |
| 93 | + | if !image.Pt(x, y).In(img.Bounds()) { | |
| 94 | + | return fmt.Errorf("pixel (%d,%d) outside %v", x, y, img.Bounds()) | |
| 95 | + | } | |
| 96 | + | got := img.At(x, y) | |
| 97 | + | if !closeColor(got, want, t) { | |
| 98 | + | return fmt.Errorf("pixel (%d,%d): got %v, want %v (tolerance %d)", x, y, channels(got), channels(want), t) | |
| 99 | + | } | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | func UniformRegion(img image.Image, r Rect, want color.Color, t uint8) error { | |
| 103 | + | if err := BoundsCheck(img, r); err != nil { | |
| 104 | + | return err | |
| 105 | + | } | |
| 106 | + | for y := r.Y; y < r.Y+r.Height; y++ { | |
| 107 | + | for x := r.X; x < r.X+r.Width; x++ { | |
| 108 | + | if err := PixelProbe(img, x, y, want, t); err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // CompareGolden produces an opaque heatmap for every pixel, even on a mismatch. | |
| 117 | + | // Pixels within tolerance are black; differing pixels show absolute RGB deltas, | |
| 118 | + | // with alpha delta in the red channel so alpha-only changes remain visible. | |
| 119 | + | func CompareGolden(got, want image.Image, t uint8, maxRatio float64) (bool, float64, *image.NRGBA, error) { | |
| 120 | + | if maxRatio < 0 || maxRatio > 1 { | |
| 121 | + | return false, 0, nil, fmt.Errorf("max_diff_ratio must be between 0 and 1") | |
| 122 | + | } | |
| 123 | + | if got.Bounds() != want.Bounds() { | |
| 124 | + | return false, 0, nil, fmt.Errorf("bounds differ: %v vs %v", got.Bounds(), want.Bounds()) | |
| 125 | + | } | |
| 126 | + | b := got.Bounds() | |
| 127 | + | if b.Empty() { | |
| 128 | + | return false, 0, nil, fmt.Errorf("empty golden") | |
| 129 | + | } | |
| 130 | + | diff := image.NewNRGBA(b) | |
| 131 | + | draw.Draw(diff, b, &image.Uniform{C: color.NRGBA{0, 0, 0, 255}}, image.Point{}, draw.Src) | |
| 132 | + | n := 0 | |
| 133 | + | for y := b.Min.Y; y < b.Max.Y; y++ { | |
| 134 | + | for x := b.Min.X; x < b.Max.X; x++ { | |
| 135 | + | a, c := channels(got.At(x, y)), channels(want.At(x, y)) | |
| 136 | + | if closeColor(a, c, t) { | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | n++ | |
| 140 | + | delta := func(a, b uint8) uint8 { | |
| 141 | + | if a > b { | |
| 142 | + | return a - b | |
| 143 | + | } | |
| 144 | + | return b - a | |
| 145 | + | } | |
| 146 | + | r := delta(a.R, c.R) | |
| 147 | + | if alpha := delta(a.A, c.A); alpha > r { | |
| 148 | + | r = alpha | |
| 149 | + | } | |
| 150 | + | diff.SetNRGBA(x, y, color.NRGBA{r, delta(a.G, c.G), delta(a.B, c.B), 255}) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ratio := float64(n) / float64(b.Dx()*b.Dy()) | |
| 154 | + | return ratio <= maxRatio, ratio, diff, nil | |
| 155 | + | } | |
| 156 | + | func SavePNG(path string, img image.Image) error { | |
| 157 | + | f, err := os.Create(path) | |
| 158 | + | if err != nil { | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | if err = png.Encode(f, img); err != nil { | |
| 162 | + | _ = f.Close() | |
| 163 | + | return err | |
| 164 | + | } | |
| 165 | + | return f.Close() | |
| 166 | + | } | |
A internal/harness/build.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "syscall" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const NeferWLSHA = "1d16b83bf90f9e8189ad21b27772382c96b9f9a8" | |
| 14 | + | ||
| 15 | + | func NeferWL(ctx context.Context) (string, error) { | |
| 16 | + | if err := ctx.Err(); err != nil { | |
| 17 | + | return "", err | |
| 18 | + | } | |
| 19 | + | if path := os.Getenv("NEFERGUI_NEFERWL"); path != "" { | |
| 20 | + | if fi, err := os.Stat(path); err != nil || fi.IsDir() || fi.Mode()&0111 == 0 { | |
| 21 | + | return "", fmt.Errorf("NEFERGUI_NEFERWL is not executable: %s", path) | |
| 22 | + | } | |
| 23 | + | return path, nil | |
| 24 | + | } | |
| 25 | + | cache := os.Getenv("XDG_CACHE_HOME") | |
| 26 | + | if cache == "" { | |
| 27 | + | var err error | |
| 28 | + | cache, err = os.UserCacheDir() | |
| 29 | + | if err != nil { | |
| 30 | + | return "", err | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | dir := filepath.Join(cache, "nefergui", "harness", "neferwl-"+NeferWLSHA) | |
| 34 | + | bin := filepath.Join(dir, "neferwl") | |
| 35 | + | if fi, err := os.Stat(bin); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 { | |
| 36 | + | return bin, nil | |
| 37 | + | } | |
| 38 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |
| 39 | + | return "", err | |
| 40 | + | } | |
| 41 | + | if err := ctx.Err(); err != nil { | |
| 42 | + | return "", err | |
| 43 | + | } | |
| 44 | + | stage, err := os.MkdirTemp(dir, "build-") | |
| 45 | + | if err != nil { | |
| 46 | + | return "", err | |
| 47 | + | } | |
| 48 | + | defer os.RemoveAll(stage) | |
| 49 | + | cmd := exec.Command("go", "install", "github.com/bnema/neferwl/cmd/neferwl@"+NeferWLSHA) | |
| 50 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 51 | + | cmd.Env = withEnv(os.Environ(), map[string]string{"GOWORK": "off", "GOBIN": stage}) | |
| 52 | + | stop := context.AfterFunc(ctx, func() { | |
| 53 | + | if cmd.Process != nil { | |
| 54 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 55 | + | } | |
| 56 | + | }) | |
| 57 | + | output, err := cmd.CombinedOutput() | |
| 58 | + | stop() | |
| 59 | + | if ctx.Err() != nil { | |
| 60 | + | return "", ctx.Err() | |
| 61 | + | } | |
| 62 | + | if err != nil { | |
| 63 | + | return "", fmt.Errorf("neferwl build: %w: %s", err, strings.TrimSpace(string(output))) | |
| 64 | + | } | |
| 65 | + | if err := os.Rename(filepath.Join(stage, "neferwl"), bin); err != nil { | |
| 66 | + | return "", err | |
| 67 | + | } | |
| 68 | + | return bin, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // isolatedEnv drops host display credentials before session-specific values are added. | |
| 72 | + | func isolatedEnv(base []string) []string { | |
| 73 | + | out := make([]string, 0, len(base)) | |
| 74 | + | for _, e := range base { | |
| 75 | + | k, _, _ := strings.Cut(e, "=") | |
| 76 | + | if k != "DISPLAY" && k != "WAYLAND_DISPLAY" && k != "WAYLAND_SOCKET" { | |
| 77 | + | out = append(out, e) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return out | |
| 81 | + | } | |
| 82 | + | func withEnv(base []string, overrides map[string]string) []string { | |
| 83 | + | out := make([]string, 0, len(base)+len(overrides)) | |
| 84 | + | for _, e := range base { | |
| 85 | + | k, _, _ := strings.Cut(e, "=") | |
| 86 | + | if _, ok := overrides[k]; ok || k == "WAYLAND_SOCKET" { | |
| 87 | + | continue | |
| 88 | + | } | |
| 89 | + | out = append(out, e) | |
| 90 | + | } | |
| 91 | + | for k, v := range overrides { | |
| 92 | + | out = append(out, k+"="+v) | |
| 93 | + | } | |
| 94 | + | return out | |
| 95 | + | } | |
A internal/harness/capture_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "image" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strconv" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCaptureSelectsByCommitTimestamp(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | token := time.Now().Add(-time.Minute).Truncate(time.Second) | |
| 16 | + | for _, tc := range []struct { | |
| 17 | + | name string | |
| 18 | + | mtime time.Time | |
| 19 | + | complete bool | |
| 20 | + | }{ | |
| 21 | + | {"frame-000001.png", token.Add(-time.Second), true}, | |
| 22 | + | {"frame-000002.png", token.Add(time.Second), true}, | |
| 23 | + | {"frame-000003.png", token.Add(2 * time.Second), false}, | |
| 24 | + | {"frame-000004.png", token.Add(3 * time.Second), true}, | |
| 25 | + | {"latest.png", token.Add(4 * time.Second), true}, | |
| 26 | + | } { | |
| 27 | + | path := filepath.Join(dir, tc.name) | |
| 28 | + | if tc.complete { | |
| 29 | + | if err := SavePNG(path, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | } else if err := os.WriteFile(path, []byte("incomplete"), 0600); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err := os.Chtimes(path, tc.mtime, tc.mtime); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | request := filepath.Join(dir, "capture-request") | |
| 40 | + | if err := os.WriteFile(request, []byte(strconv.FormatInt(token.UnixNano(), 10)), 0600); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | after, err := captureRequestTime(request) | |
| 44 | + | if err != nil || !after.Equal(token) { | |
| 45 | + | t.Fatalf("token %v %v", after, err) | |
| 46 | + | } | |
| 47 | + | // frame-2 finished after the token but may have been rendered before it. | |
| 48 | + | name, err := nextFrame(dir, after) | |
| 49 | + | if err != nil || name != "frame-000003.png" { | |
| 50 | + | t.Fatalf("selected %q %v", name, err) | |
| 51 | + | } | |
| 52 | + | if name, err := nextFrame(dir, token.Add(2500*time.Millisecond)); err != nil || name != "" { | |
| 53 | + | t.Fatalf("one screenshot after the token must not be selected: %q %v", name, err) | |
| 54 | + | } | |
| 55 | + | capture := filepath.Join(dir, "capture.png") | |
| 56 | + | if err := copyNextFrame(dir, after, capture); !errors.Is(err, os.ErrNotExist) { | |
| 57 | + | t.Fatalf("incomplete frame accepted: %v", err) | |
| 58 | + | } | |
| 59 | + | if err := SavePNG(filepath.Join(dir, name), image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if err := os.Chtimes(filepath.Join(dir, name), token.Add(2*time.Second), token.Add(2*time.Second)); err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | if err := copyNextFrame(dir, after, capture); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if got, err := LoadPNG(capture); err != nil || got.Bounds().Dx() != 2 { | |
| 69 | + | t.Fatalf("capture %v %v", got, err) | |
| 70 | + | } | |
| 71 | + | if err := os.WriteFile(request, []byte("not-a-time"), 0600); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if _, err := captureRequestTime(request); err == nil { | |
| 75 | + | t.Fatal("invalid token accepted") | |
| 76 | + | } | |
| 77 | + | } | |
A internal/harness/config.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "regexp" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var sizePattern = regexp.MustCompile(`^[1-9][0-9]*x[1-9][0-9]*$`) | |
| 14 | + | var layoutPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) | |
| 15 | + | var socketPattern = regexp.MustCompile(`^wayland-[0-9]+$`) | |
| 16 | + | ||
| 17 | + | func Config(size, scale, layout, background string) (string, error) { | |
| 18 | + | if !sizePattern.MatchString(size) || !layoutPattern.MatchString(layout) { | |
| 19 | + | return "", fmt.Errorf("invalid size or layout") | |
| 20 | + | } | |
| 21 | + | if scale != "1" && scale != "1.25" && scale != "1.5" && scale != "2" { | |
| 22 | + | return "", fmt.Errorf("unsupported scale %q", scale) | |
| 23 | + | } | |
| 24 | + | if _, err := ParseColor(background); err != nil { | |
| 25 | + | return "", err | |
| 26 | + | } | |
| 27 | + | // Wayland debug logs record each client cursor shape change, which | |
| 28 | + | // systemtests read from the compositor run log. | |
| 29 | + | return fmt.Sprintf("output.HEADLESS-1 = %s\noutput.HEADLESS-1.scale = %s\nkeyboard.layout = %s\nbackground = %s\nlog.debug = wayland\n", size, scale, layout, background), nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // DiscoverSocket only accepts a real Unix socket paired with NeferWL's state file. | |
| 33 | + | // A fresh private runtime directory prevents picking up a foreign session. | |
| 34 | + | func DiscoverSocket(ctx context.Context, runtime string) (string, error) { | |
| 35 | + | entries, err := os.ReadDir(filepath.Join(runtime, "neferwl")) | |
| 36 | + | if os.IsNotExist(err) { | |
| 37 | + | return "", nil | |
| 38 | + | } | |
| 39 | + | if err != nil { | |
| 40 | + | return "", err | |
| 41 | + | } | |
| 42 | + | var names []string | |
| 43 | + | for _, e := range entries { | |
| 44 | + | if err := ctx.Err(); err != nil { | |
| 45 | + | return "", err | |
| 46 | + | } | |
| 47 | + | name, ok := strings.CutSuffix(e.Name(), ".json") | |
| 48 | + | if !ok || !socketPattern.MatchString(name) { | |
| 49 | + | continue | |
| 50 | + | } | |
| 51 | + | fi, err := os.Lstat(filepath.Join(runtime, name)) | |
| 52 | + | if err == nil && fi.Mode()&os.ModeSocket != 0 { | |
| 53 | + | c, err := (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(runtime, name)) | |
| 54 | + | if err == nil { | |
| 55 | + | _ = c.Close() | |
| 56 | + | names = append(names, name) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if len(names) > 1 { | |
| 61 | + | return "", fmt.Errorf("multiple compositor sockets: %v", names) | |
| 62 | + | } | |
| 63 | + | if len(names) == 1 { | |
| 64 | + | return names[0], nil | |
| 65 | + | } | |
| 66 | + | return "", nil | |
| 67 | + | } | |
A internal/harness/harness_test.go +331 -0
| @@ -0,0 +1,331 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "image" | |
| 6 | + | "image/color" | |
| 7 | + | "net" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestAssertions(t *testing.T) { | |
| 17 | + | img := image.NewNRGBA(image.Rect(4, 5, 6, 7)) | |
| 18 | + | c := color.NRGBA{51, 102, 153, 255} | |
| 19 | + | for y := 5; y < 7; y++ { | |
| 20 | + | for x := 4; x < 6; x++ { | |
| 21 | + | img.Set(x, y, c) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | for _, tc := range []struct { | |
| 25 | + | name string | |
| 26 | + | err error | |
| 27 | + | pass bool | |
| 28 | + | }{ | |
| 29 | + | {"probe", PixelProbe(img, 4, 5, c, 0), true}, {"outside", PixelProbe(img, 0, 0, c, 0), false}, | |
| 30 | + | {"tolerated", PixelProbe(img, 4, 5, color.NRGBA{52, 102, 153, 255}, 1), true}, | |
| 31 | + | {"color", UniformRegion(img, Rect{4, 5, 2, 2}, c, 0), true}, | |
| 32 | + | {"bounds", BoundsCheck(img, Rect{4, 5, 3, 2}), false}, {"empty", BoundsCheck(img, Rect{4, 5, 0, 2}), false}, | |
| 33 | + | } { | |
| 34 | + | t.Run(tc.name, func(t *testing.T) { | |
| 35 | + | if (tc.err == nil) != tc.pass { | |
| 36 | + | t.Fatalf("error %v, want pass %v", tc.err, tc.pass) | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | changed := image.NewNRGBA(img.Bounds()) | |
| 41 | + | for y := 5; y < 7; y++ { | |
| 42 | + | for x := 4; x < 6; x++ { | |
| 43 | + | changed.Set(x, y, c) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | changed.Set(4, 5, color.NRGBA{54, 102, 153, 255}) | |
| 47 | + | for _, tc := range []struct { | |
| 48 | + | tolerance uint8 | |
| 49 | + | max float64 | |
| 50 | + | pass bool | |
| 51 | + | ratio float64 | |
| 52 | + | }{{0, .24, false, .25}, {0, .25, true, .25}, {3, 0, true, 0}} { | |
| 53 | + | pass, ratio, diff, err := CompareGolden(changed, img, tc.tolerance, tc.max) | |
| 54 | + | if err != nil || pass != tc.pass || ratio != tc.ratio || diff == nil { | |
| 55 | + | t.Fatalf("%+v: %v %v %v", tc, pass, ratio, err) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | alpha := image.NewNRGBA(img.Bounds()) | |
| 59 | + | for y := 5; y < 7; y++ { | |
| 60 | + | for x := 4; x < 6; x++ { | |
| 61 | + | alpha.SetNRGBA(x, y, c) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | alpha.SetNRGBA(4, 5, color.NRGBA{51, 102, 153, 254}) | |
| 65 | + | pass, ratio, diff, err := CompareGolden(alpha, img, 0, 0) | |
| 66 | + | if err != nil || pass || ratio != .25 || diff == nil || diff.NRGBAAt(4, 5) == (color.NRGBA{0, 0, 0, 255}) { | |
| 67 | + | t.Fatalf("alpha diff: %v %v %v %v", pass, ratio, diff, err) | |
| 68 | + | } | |
| 69 | + | if _, _, _, err := CompareGolden(img, image.NewNRGBA(image.Rect(0, 0, 1, 1)), 0, 0); err == nil { | |
| 70 | + | t.Fatal("different bounds accepted") | |
| 71 | + | } | |
| 72 | + | if _, _, _, err := CompareGolden(img, img, 0, 1.1); err == nil { | |
| 73 | + | t.Fatal("invalid ratio accepted") | |
| 74 | + | } | |
| 75 | + | for _, s := range []string{"red", "#GG0000", "#12345"} { | |
| 76 | + | if _, err := ParseColor(s); err == nil { | |
| 77 | + | t.Fatalf("accepted %q", s) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | dir := t.TempDir() | |
| 81 | + | path := filepath.Join(dir, "img.png") | |
| 82 | + | if err := SavePNG(path, img); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | decoded, err := LoadPNG(path) | |
| 86 | + | if err != nil || decoded.Bounds().Dx() != img.Bounds().Dx() || decoded.Bounds().Dy() != img.Bounds().Dy() { | |
| 87 | + | t.Fatalf("png: %v", err) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | func TestConfig(t *testing.T) { | |
| 91 | + | cfg, err := Config("800x600", "1.5", "fr", "#336699") | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | for _, s := range []string{"output.HEADLESS-1 = 800x600", "output.HEADLESS-1.scale = 1.5", "keyboard.layout = fr", "background = #336699", "log.debug = wayland"} { | |
| 96 | + | if !strings.Contains(cfg, s) { | |
| 97 | + | t.Fatalf("missing %s", s) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} { | |
| 101 | + | if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil { | |
| 102 | + | t.Fatalf("accepted %v", tc) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | func TestDiscoverSocket(t *testing.T) { | |
| 107 | + | dir := t.TempDir() | |
| 108 | + | if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "" { | |
| 109 | + | t.Fatalf("empty: %q %v", name, err) | |
| 110 | + | } | |
| 111 | + | if err := os.Mkdir(filepath.Join(dir, "neferwl"), 0700); err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-1.json"), []byte("{}"), 0600); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if name, _ := DiscoverSocket(context.Background(), dir); name != "" { | |
| 118 | + | t.Fatal("state without socket") | |
| 119 | + | } | |
| 120 | + | ln, err := net.Listen("unix", filepath.Join(dir, "wayland-1")) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | defer ln.Close() | |
| 125 | + | if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "wayland-1" { | |
| 126 | + | t.Fatalf("socket: %q %v", name, err) | |
| 127 | + | } | |
| 128 | + | if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-2.json"), []byte("{}"), 0600); err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | ln2, err := net.Listen("unix", filepath.Join(dir, "wayland-2")) | |
| 132 | + | if err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | defer ln2.Close() | |
| 136 | + | if _, err := DiscoverSocket(context.Background(), dir); err == nil { | |
| 137 | + | t.Fatal("multiple sockets accepted") | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | func TestHeadlessBackground(t *testing.T) { | |
| 141 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 142 | + | t.Skip("set NEFERGUI_HARNESS=1") | |
| 143 | + | } | |
| 144 | + | out := filepath.Join(t.TempDir(), "run") | |
| 145 | + | res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1.5", Layout: "fr", Background: "#336699", Out: out, Timeout: 60 * time.Second, ReadyTimeout: 20 * time.Second, Expect: Expectations{Probes: []Probe{{Name: "background", X: 64, Y: 48, Color: "#336699", Tolerance: 1}}}}) | |
| 146 | + | t.Logf("code=%d result=%+v", code, res) | |
| 147 | + | if code != Pass { | |
| 148 | + | data, _ := os.ReadFile(filepath.Join(out, "logs", "compositor.log")) | |
| 149 | + | t.Fatalf("run: %s\ncompositor:\n%s", res.Error, data) | |
| 150 | + | } | |
| 151 | + | if len(res.Checks) != 1 || !res.Checks[0].Pass { | |
| 152 | + | t.Fatal(res.Checks) | |
| 153 | + | } | |
| 154 | + | if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestClientFailureAndCleanup(t *testing.T) { | |
| 160 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 161 | + | t.Skip("set NEFERGUI_HARNESS=1") | |
| 162 | + | } | |
| 163 | + | isolateSessionTemp(t) | |
| 164 | + | out := filepath.Join(t.TempDir(), "failure") | |
| 165 | + | res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 10 * time.Second, ReadyTimeout: 5 * time.Second, Client: []string{"/bin/false"}}) | |
| 166 | + | if code != ProcessFailure || res.Status != "error" { | |
| 167 | + | t.Fatalf("code=%d result=%+v", code, res) | |
| 168 | + | } | |
| 169 | + | if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | checkCleanup(t, res) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func checkCleanup(t *testing.T, res Result) { | |
| 176 | + | t.Helper() | |
| 177 | + | if _, ok := res.Artifacts["session"]; ok { | |
| 178 | + | t.Fatal("temporary session retained") | |
| 179 | + | } | |
| 180 | + | matches, err := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), "nefergui-harness-*")) | |
| 181 | + | if err != nil || len(matches) != 0 { | |
| 182 | + | t.Fatalf("temporary sessions remain: %v %v", matches, err) | |
| 183 | + | } | |
| 184 | + | if res.Compositor.PID <= 0 { | |
| 185 | + | t.Fatalf("missing compositor pid: %+v", res) | |
| 186 | + | } | |
| 187 | + | if err := syscall.Kill(res.Compositor.PID, 0); err != syscall.ESRCH { | |
| 188 | + | t.Fatalf("compositor pid %d not reaped: %v", res.Compositor.PID, err) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func isolateSessionTemp(t *testing.T) { t.Helper(); t.Setenv("TMPDIR", t.TempDir()) } | |
| 193 | + | ||
| 194 | + | func TestCancelAndCleanup(t *testing.T) { | |
| 195 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 196 | + | t.Skip("set NEFERGUI_HARNESS=1") | |
| 197 | + | } | |
| 198 | + | isolateSessionTemp(t) | |
| 199 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 200 | + | defer cancel() | |
| 201 | + | out := filepath.Join(t.TempDir(), "cancel") | |
| 202 | + | ready := filepath.Join(t.TempDir(), "ready") | |
| 203 | + | result := make(chan struct { | |
| 204 | + | res Result | |
| 205 | + | code int | |
| 206 | + | }, 1) | |
| 207 | + | go func() { | |
| 208 | + | res, code := Run(ctx, Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "touch \"$1\"; sleep 30", "sh", ready}}) | |
| 209 | + | result <- struct { | |
| 210 | + | res Result | |
| 211 | + | code int | |
| 212 | + | }{res, code} | |
| 213 | + | }() | |
| 214 | + | deadline := time.After(12 * time.Second) | |
| 215 | + | for { | |
| 216 | + | if _, err := os.Stat(ready); err == nil { | |
| 217 | + | break | |
| 218 | + | } | |
| 219 | + | select { | |
| 220 | + | case <-deadline: | |
| 221 | + | cancel() | |
| 222 | + | t.Fatal("client never started") | |
| 223 | + | case <-time.After(25 * time.Millisecond): | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | cancel() | |
| 227 | + | r := <-result | |
| 228 | + | if r.code != ProcessFailure || !strings.Contains(r.res.Error, "context canceled") { | |
| 229 | + | t.Fatalf("cancel: %d %+v", r.code, r.res) | |
| 230 | + | } | |
| 231 | + | checkCleanup(t, r.res) | |
| 232 | + | if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestClientEnvironment(t *testing.T) { | |
| 238 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 239 | + | t.Skip("set NEFERGUI_HARNESS=1") | |
| 240 | + | } | |
| 241 | + | isolateSessionTemp(t) | |
| 242 | + | t.Setenv("DISPLAY", ":99") | |
| 243 | + | t.Setenv("WAYLAND_DISPLAY", "host-socket") | |
| 244 | + | t.Setenv("WAYLAND_SOCKET", "42") | |
| 245 | + | out := filepath.Join(t.TempDir(), "env") | |
| 246 | + | envFile := filepath.Join(t.TempDir(), "client.env") | |
| 247 | + | res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "env > \"$1\"", "sh", envFile}}) | |
| 248 | + | if code != Pass { | |
| 249 | + | t.Fatalf("run: %d %+v", code, res) | |
| 250 | + | } | |
| 251 | + | b, err := os.ReadFile(envFile) | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatal(err) | |
| 254 | + | } | |
| 255 | + | env := map[string]string{} | |
| 256 | + | for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { | |
| 257 | + | k, v, ok := strings.Cut(line, "=") | |
| 258 | + | if ok { | |
| 259 | + | env[k] = v | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | for _, k := range []string{"DISPLAY", "WAYLAND_SOCKET"} { | |
| 263 | + | if _, ok := env[k]; ok { | |
| 264 | + | t.Fatalf("host %s leaked: %s", k, b) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | if env["WAYLAND_DISPLAY"] == "" || env["WAYLAND_DISPLAY"] == "host-socket" { | |
| 268 | + | t.Fatalf("wrong socket: %s", b) | |
| 269 | + | } | |
| 270 | + | if runtime := env["XDG_RUNTIME_DIR"]; !strings.HasPrefix(runtime, filepath.Join(os.TempDir(), "nefergui-harness-")) || filepath.Base(runtime) != "runtime" { | |
| 271 | + | t.Fatalf("wrong runtime: %s", b) | |
| 272 | + | } | |
| 273 | + | checkCleanup(t, res) | |
| 274 | + | } | |
| 275 | + | func TestUnpinnedOverride(t *testing.T) { | |
| 276 | + | t.Setenv("NEFERGUI_NEFERWL", "/bin/true") | |
| 277 | + | options := Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: filepath.Join(t.TempDir(), "denied"), Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond} | |
| 278 | + | res, code := Run(context.Background(), options) | |
| 279 | + | if code != Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" { | |
| 280 | + | t.Fatalf("denied: %d %+v", code, res) | |
| 281 | + | } | |
| 282 | + | data, err := os.ReadFile(filepath.Join(options.Out, "result.json")) | |
| 283 | + | if err != nil { | |
| 284 | + | t.Fatal(err) | |
| 285 | + | } | |
| 286 | + | if !strings.Contains(string(data), `"pinned": false`) || !strings.Contains(string(data), `"binary": "/bin/true"`) { | |
| 287 | + | t.Fatalf("result: %s", data) | |
| 288 | + | } | |
| 289 | + | options.Out = filepath.Join(t.TempDir(), "allowed") | |
| 290 | + | options.AllowUnpinned = true | |
| 291 | + | res, code = Run(context.Background(), options) | |
| 292 | + | if code == Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" { | |
| 293 | + | t.Fatalf("allowed: %d %+v", code, res) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestResultWriteFailure(t *testing.T) { | |
| 298 | + | out := filepath.Join(t.TempDir(), "run") | |
| 299 | + | binary := filepath.Join(t.TempDir(), "fake-neferwl") | |
| 300 | + | if err := os.WriteFile(binary, []byte("#!/bin/sh\nmkdir \""+filepath.Join(out, "result.json")+"\"\n"), 0700); err != nil { | |
| 301 | + | t.Fatal(err) | |
| 302 | + | } | |
| 303 | + | t.Setenv("NEFERGUI_NEFERWL", binary) | |
| 304 | + | res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, AllowUnpinned: true, Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond}) | |
| 305 | + | if code != ProcessFailure || !strings.Contains(res.Error, "write result.json") { | |
| 306 | + | t.Fatalf("write failure: %d %+v", code, res) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestCopyRunLog(t *testing.T) { | |
| 311 | + | dir := t.TempDir() | |
| 312 | + | dst := filepath.Join(dir, "out.log") | |
| 313 | + | // Older NeferWL writes no run log: no artifact, no error. | |
| 314 | + | if ok, err := copyRunLog(filepath.Join(dir, "missing.log"), dst); ok || err != nil { | |
| 315 | + | t.Fatalf("missing: %v %v", ok, err) | |
| 316 | + | } | |
| 317 | + | src := filepath.Join(dir, "latest.log") | |
| 318 | + | if err := os.WriteFile(src, []byte("{}\n"), 0600); err != nil { | |
| 319 | + | t.Fatal(err) | |
| 320 | + | } | |
| 321 | + | if ok, err := copyRunLog(src, dst); !ok || err != nil { | |
| 322 | + | t.Fatalf("copy: %v %v", ok, err) | |
| 323 | + | } | |
| 324 | + | if data, err := os.ReadFile(dst); err != nil || string(data) != "{}\n" { | |
| 325 | + | t.Fatalf("copied %q %v", data, err) | |
| 326 | + | } | |
| 327 | + | // A failed write is reported, not hidden. | |
| 328 | + | if ok, err := copyRunLog(src, filepath.Join(dir, "no-such-dir", "out.log")); ok || err == nil { | |
| 329 | + | t.Fatalf("write failure: %v %v", ok, err) | |
| 330 | + | } | |
| 331 | + | } | |
A internal/harness/run.go +569 -0
| @@ -0,0 +1,569 @@ | |||
| 1 | + | package harness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io/fs" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | Pass = 0 | |
| 20 | + | Failed = 1 | |
| 21 | + | Usage = 2 | |
| 22 | + | Unavailable = 3 | |
| 23 | + | ProcessFailure = 4 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | type Options struct { | |
| 27 | + | Size string | |
| 28 | + | Scale string | |
| 29 | + | Layout string | |
| 30 | + | Background string | |
| 31 | + | Input string | |
| 32 | + | Out string | |
| 33 | + | Keep bool | |
| 34 | + | AllowUnpinned bool | |
| 35 | + | Timeout time.Duration | |
| 36 | + | ReadyTimeout time.Duration | |
| 37 | + | Client []string | |
| 38 | + | Expect Expectations | |
| 39 | + | GoldenBase string | |
| 40 | + | } | |
| 41 | + | type Compositor struct { | |
| 42 | + | Pinned bool `json:"pinned"` | |
| 43 | + | SHA string `json:"sha,omitempty"` | |
| 44 | + | Binary string `json:"binary,omitempty"` | |
| 45 | + | PID int `json:"pid,omitempty"` | |
| 46 | + | } | |
| 47 | + | type Result struct { | |
| 48 | + | Compositor Compositor `json:"compositor"` | |
| 49 | + | Schema string `json:"schema"` | |
| 50 | + | Status string `json:"status"` | |
| 51 | + | Checks []Check `json:"checks"` | |
| 52 | + | Artifacts map[string]string `json:"artifacts"` | |
| 53 | + | Timings map[string]string `json:"timings"` | |
| 54 | + | Error string `json:"error,omitempty"` | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func validate(o Options) error { | |
| 58 | + | if _, err := Config(o.Size, o.Scale, o.Layout, o.Background); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | if o.Out == "" || o.Timeout <= 0 || o.ReadyTimeout <= 0 || o.ReadyTimeout >= o.Timeout { | |
| 62 | + | return fmt.Errorf("out and positive timeouts (ready < total) required") | |
| 63 | + | } | |
| 64 | + | if o.Input != "" { | |
| 65 | + | if fi, err := os.Stat(o.Input); err != nil || !fi.Mode().IsRegular() { | |
| 66 | + | return fmt.Errorf("input script not readable: %s", o.Input) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | for _, p := range o.Expect.Probes { | |
| 70 | + | if _, err := ParseColor(p.Color); err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | for _, r := range o.Expect.Regions { | |
| 75 | + | if r.Color != "" { | |
| 76 | + | if _, err := ParseColor(r.Color); err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | for _, g := range o.Expect.Goldens { | |
| 82 | + | if g.MaxDiffRatio < 0 || g.MaxDiffRatio > 1 || g.Path == "" { | |
| 83 | + | return fmt.Errorf("invalid golden %q", g.Name) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // copyRunLog copies the compositor's structured run log into the artifacts. | |
| 90 | + | // A missing log (older NeferWL, early failure) is not an error; it reports | |
| 91 | + | // whether dst was written. | |
| 92 | + | func copyRunLog(src, dst string) (bool, error) { | |
| 93 | + | data, err := os.ReadFile(src) | |
| 94 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 95 | + | return false, nil | |
| 96 | + | } | |
| 97 | + | if err == nil { | |
| 98 | + | err = os.WriteFile(dst, data, 0600) | |
| 99 | + | } | |
| 100 | + | return err == nil, err | |
| 101 | + | } | |
| 102 | + | func newLog(path string) (*os.File, error) { | |
| 103 | + | return os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) | |
| 104 | + | } | |
| 105 | + | func waitSocket(ctx context.Context, runtime string, exited <-chan error) (string, error) { | |
| 106 | + | ticker := time.NewTicker(25 * time.Millisecond) | |
| 107 | + | defer ticker.Stop() | |
| 108 | + | for { | |
| 109 | + | name, err := DiscoverSocket(ctx, runtime) | |
| 110 | + | if err != nil || name != "" { | |
| 111 | + | if ctx.Err() != nil { | |
| 112 | + | return "", ctx.Err() | |
| 113 | + | } | |
| 114 | + | return name, err | |
| 115 | + | } | |
| 116 | + | select { | |
| 117 | + | case err := <-exited: | |
| 118 | + | return "", fmt.Errorf("compositor exited before ready: %v", err) | |
| 119 | + | case <-ctx.Done(): | |
| 120 | + | return "", ctx.Err() | |
| 121 | + | case <-ticker.C: | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Run writes result.json for every run that has acquired an artifact directory. | |
| 127 | + | // It never owns client-side debug files: the client receives NEFERGUI_DEBUG_DIR and writes them. | |
| 128 | + | func Run(ctx context.Context, o Options) (res Result, code int) { | |
| 129 | + | res = Result{Schema: "nefergui-harness/v1", Status: "error", Checks: []Check{}, Artifacts: map[string]string{}, Timings: map[string]string{}} | |
| 130 | + | code = ProcessFailure | |
| 131 | + | if err := validate(o); err != nil { | |
| 132 | + | res.Error = err.Error() | |
| 133 | + | return res, Usage | |
| 134 | + | } | |
| 135 | + | start := time.Now() | |
| 136 | + | runCtx, cancel := context.WithTimeout(ctx, o.Timeout) | |
| 137 | + | defer cancel() | |
| 138 | + | out, err := filepath.Abs(o.Out) | |
| 139 | + | if err != nil { | |
| 140 | + | res.Error = err.Error() | |
| 141 | + | return res, Usage | |
| 142 | + | } | |
| 143 | + | if err = os.Mkdir(out, 0700); err != nil { | |
| 144 | + | res.Error = fmt.Sprintf("out must be a new directory: %v", err) | |
| 145 | + | return res, Usage | |
| 146 | + | } | |
| 147 | + | res.Artifacts["root"] = out | |
| 148 | + | defer func() { | |
| 149 | + | res.Timings["total"] = time.Since(start).String() | |
| 150 | + | data, e := json.MarshalIndent(res, "", " ") | |
| 151 | + | if e == nil { | |
| 152 | + | e = os.WriteFile(filepath.Join(out, "result.json"), append(data, '\n'), 0600) | |
| 153 | + | } | |
| 154 | + | if e != nil { | |
| 155 | + | writeErr := fmt.Errorf("write result.json: %w", e) | |
| 156 | + | if res.Error != "" { | |
| 157 | + | writeErr = errors.Join(errors.New(res.Error), writeErr) | |
| 158 | + | } | |
| 159 | + | res.Error = writeErr.Error() | |
| 160 | + | res.Status = "error" | |
| 161 | + | code = ProcessFailure | |
| 162 | + | } | |
| 163 | + | }() | |
| 164 | + | fail := func(err error, c int) (Result, int) { res.Error = err.Error(); return res, c } | |
| 165 | + | for key, dir := range map[string]string{"frames": "frames", "debug": "client-debug", "logs": "logs"} { | |
| 166 | + | path := filepath.Join(out, dir) | |
| 167 | + | if err = os.Mkdir(path, 0700); err != nil { | |
| 168 | + | return fail(err, ProcessFailure) | |
| 169 | + | } | |
| 170 | + | res.Artifacts[key] = path | |
| 171 | + | } | |
| 172 | + | res.Artifacts["frame"] = filepath.Join(out, "frames", "latest.png") | |
| 173 | + | res.Artifacts["compositor_log"] = filepath.Join(out, "logs", "compositor.log") | |
| 174 | + | res.Artifacts["client_stdout"] = filepath.Join(out, "logs", "client.stdout") | |
| 175 | + | res.Artifacts["client_stderr"] = filepath.Join(out, "logs", "client.stderr") | |
| 176 | + | session, err := os.MkdirTemp("", "nefergui-harness-") | |
| 177 | + | if err != nil { | |
| 178 | + | return fail(err, ProcessFailure) | |
| 179 | + | } | |
| 180 | + | if !o.Keep { | |
| 181 | + | defer os.RemoveAll(session) | |
| 182 | + | } else { | |
| 183 | + | res.Artifacts["session"] = session | |
| 184 | + | } | |
| 185 | + | runtime := filepath.Join(session, "runtime") | |
| 186 | + | configHome := filepath.Join(session, "config") | |
| 187 | + | for _, d := range []string{runtime, filepath.Join(configHome, "neferwl")} { | |
| 188 | + | if err = os.MkdirAll(d, 0700); err != nil { | |
| 189 | + | return fail(err, ProcessFailure) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | cfg, err := Config(o.Size, o.Scale, o.Layout, o.Background) | |
| 193 | + | if err != nil { | |
| 194 | + | return fail(err, Usage) | |
| 195 | + | } | |
| 196 | + | if err = os.WriteFile(filepath.Join(configHome, "neferwl", "config"), []byte(cfg), 0600); err != nil { | |
| 197 | + | return fail(err, ProcessFailure) | |
| 198 | + | } | |
| 199 | + | buildStart := time.Now() | |
| 200 | + | if path := os.Getenv("NEFERGUI_NEFERWL"); path != "" { | |
| 201 | + | res.Compositor = Compositor{Binary: path} | |
| 202 | + | if !o.AllowUnpinned { | |
| 203 | + | return fail(fmt.Errorf("NEFERGUI_NEFERWL requires --allow-unpinned"), Usage) | |
| 204 | + | } | |
| 205 | + | } else { | |
| 206 | + | res.Compositor = Compositor{Pinned: true, SHA: NeferWLSHA} | |
| 207 | + | } | |
| 208 | + | buildCtx, buildCancel := context.WithTimeout(runCtx, 3*time.Minute) | |
| 209 | + | binary, err := NeferWL(buildCtx) | |
| 210 | + | buildCancel() | |
| 211 | + | res.Timings["build"] = time.Since(buildStart).String() | |
| 212 | + | if err != nil { | |
| 213 | + | return fail(err, Unavailable) | |
| 214 | + | } | |
| 215 | + | if err = runCtx.Err(); err != nil { | |
| 216 | + | return fail(err, Unavailable) | |
| 217 | + | } | |
| 218 | + | logFile, err := newLog(res.Artifacts["compositor_log"]) | |
| 219 | + | if err != nil { | |
| 220 | + | return fail(err, ProcessFailure) | |
| 221 | + | } | |
| 222 | + | defer logFile.Close() | |
| 223 | + | args := []string{"--backend=headless", "--no-terminal", "--no-xwayland", "--screenshot", res.Artifacts["frames"], "--size", o.Size, "--timeout", o.Timeout.String()} | |
| 224 | + | if o.Input != "" { | |
| 225 | + | args = append(args, "--input", o.Input) | |
| 226 | + | } | |
| 227 | + | cmd := exec.Command(binary, args...) | |
| 228 | + | // NeferWL writes structured run logs under XDG_STATE_HOME; keep them in | |
| 229 | + | // the session instead of the host state directory, and copy them out. | |
| 230 | + | stateHome := filepath.Join(session, "state") | |
| 231 | + | cmd.Env = withEnv(isolatedEnv(os.Environ()), map[string]string{"XDG_RUNTIME_DIR": runtime, "XDG_CONFIG_HOME": configHome, "XDG_STATE_HOME": stateHome, "WAYLAND_DISPLAY": ""}) | |
| 232 | + | // Registered before the reap defer, so it runs after the compositor exits. | |
| 233 | + | defer func() { | |
| 234 | + | dst := filepath.Join(out, "logs", "compositor-run.log") | |
| 235 | + | ok, e := copyRunLog(filepath.Join(stateHome, "neferwl", "runs", "headless", "latest.log"), dst) | |
| 236 | + | if ok { | |
| 237 | + | res.Artifacts["compositor_run_log"] = dst | |
| 238 | + | } | |
| 239 | + | if e != nil { | |
| 240 | + | copyErr := fmt.Errorf("copy compositor run log: %w", e) | |
| 241 | + | if res.Error != "" { | |
| 242 | + | copyErr = errors.Join(errors.New(res.Error), copyErr) | |
| 243 | + | } | |
| 244 | + | res.Error, res.Status, code = copyErr.Error(), "error", ProcessFailure | |
| 245 | + | } | |
| 246 | + | }() | |
| 247 | + | cmd.Stdout = logFile | |
| 248 | + | cmd.Stderr = logFile | |
| 249 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 250 | + | if err = cmd.Start(); err != nil { | |
| 251 | + | return fail(err, Unavailable) | |
| 252 | + | } | |
| 253 | + | res.Compositor.PID = cmd.Process.Pid | |
| 254 | + | exited := make(chan error, 1) | |
| 255 | + | go func() { exited <- cmd.Wait(); close(exited) }() | |
| 256 | + | // Always reap the process; killing the group also stops any children. | |
| 257 | + | defer func() { | |
| 258 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 259 | + | <-exited | |
| 260 | + | }() | |
| 261 | + | readyCtx, readyCancel := context.WithTimeout(runCtx, o.ReadyTimeout) | |
| 262 | + | defer readyCancel() | |
| 263 | + | readyStart := time.Now() | |
| 264 | + | socket, err := waitSocket(readyCtx, runtime, exited) | |
| 265 | + | res.Timings["ready"] = time.Since(readyStart).String() | |
| 266 | + | if err != nil { | |
| 267 | + | return fail(err, Unavailable) | |
| 268 | + | } | |
| 269 | + | // Create empty logs even for compositor-only sessions. | |
| 270 | + | for _, path := range []string{res.Artifacts["client_stdout"], res.Artifacts["client_stderr"]} { | |
| 271 | + | f, e := newLog(path) | |
| 272 | + | if e != nil { | |
| 273 | + | return fail(e, ProcessFailure) | |
| 274 | + | } | |
| 275 | + | _ = f.Close() | |
| 276 | + | } | |
| 277 | + | env := withEnv(isolatedEnv(os.Environ()), map[string]string{"XDG_RUNTIME_DIR": runtime, "XDG_CONFIG_HOME": configHome, "WAYLAND_DISPLAY": socket, "NEFERGUI_DEBUG_DIR": res.Artifacts["debug"]}) | |
| 278 | + | if len(o.Client) > 0 { | |
| 279 | + | stdout, e := os.OpenFile(res.Artifacts["client_stdout"], os.O_WRONLY, 0600) | |
| 280 | + | if e != nil { | |
| 281 | + | return fail(e, ProcessFailure) | |
| 282 | + | } | |
| 283 | + | defer stdout.Close() | |
| 284 | + | stderr, e := os.OpenFile(res.Artifacts["client_stderr"], os.O_WRONLY, 0600) | |
| 285 | + | if e != nil { | |
| 286 | + | return fail(e, ProcessFailure) | |
| 287 | + | } | |
| 288 | + | defer stderr.Close() | |
| 289 | + | client := exec.Command(o.Client[0], o.Client[1:]...) | |
| 290 | + | client.Env = env | |
| 291 | + | client.Stdout = stdout | |
| 292 | + | client.Stderr = stderr | |
| 293 | + | client.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 294 | + | if err = client.Start(); err != nil { | |
| 295 | + | return fail(err, ProcessFailure) | |
| 296 | + | } | |
| 297 | + | clientExit := make(chan error, 1) | |
| 298 | + | go func() { clientExit <- client.Wait(); close(clientExit) }() | |
| 299 | + | defer func() { | |
| 300 | + | _ = syscall.Kill(-client.Process.Pid, syscall.SIGKILL) | |
| 301 | + | <-clientExit | |
| 302 | + | }() | |
| 303 | + | clientStart := time.Now() | |
| 304 | + | capturePath := filepath.Join(res.Artifacts["frames"], "capture.png") | |
| 305 | + | requestPath := filepath.Join(res.Artifacts["debug"], "capture-request") | |
| 306 | + | requestSeen := false | |
| 307 | + | var requestedAt time.Time | |
| 308 | + | captureTick := time.NewTicker(10 * time.Millisecond) | |
| 309 | + | defer captureTick.Stop() | |
| 310 | + | clientLoop: | |
| 311 | + | for { | |
| 312 | + | select { | |
| 313 | + | case err = <-clientExit: | |
| 314 | + | break clientLoop | |
| 315 | + | case err = <-exited: | |
| 316 | + | err = fmt.Errorf("compositor exited during client: %v", err) | |
| 317 | + | break clientLoop | |
| 318 | + | case <-runCtx.Done(): | |
| 319 | + | err = runCtx.Err() | |
| 320 | + | break clientLoop | |
| 321 | + | case <-captureTick.C: | |
| 322 | + | if !requestSeen { | |
| 323 | + | requestedAt, err = captureRequestTime(requestPath) | |
| 324 | + | if errors.Is(err, os.ErrNotExist) { | |
| 325 | + | err = nil | |
| 326 | + | } else if err == nil { | |
| 327 | + | requestSeen = true | |
| 328 | + | } else { | |
| 329 | + | break clientLoop | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | if requestSeen { | |
| 333 | + | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | |
| 334 | + | if err == nil { | |
| 335 | + | res.Artifacts["capture"] = capturePath | |
| 336 | + | requestSeen = false | |
| 337 | + | break clientLoop | |
| 338 | + | } | |
| 339 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 340 | + | break clientLoop | |
| 341 | + | } | |
| 342 | + | err = nil | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | // After capture, still wait for the client to finish normally. | |
| 347 | + | if res.Artifacts["capture"] != "" && err == nil { | |
| 348 | + | select { | |
| 349 | + | case err = <-clientExit: | |
| 350 | + | case err = <-exited: | |
| 351 | + | err = fmt.Errorf("compositor exited during client: %v", err) | |
| 352 | + | case <-runCtx.Done(): | |
| 353 | + | err = runCtx.Err() | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | res.Timings["client"] = time.Since(clientStart).String() | |
| 357 | + | // A request near client exit must not silently fall back to latest.png. | |
| 358 | + | if err == nil && res.Artifacts["capture"] == "" { | |
| 359 | + | if !requestSeen { | |
| 360 | + | requestedAt, err = captureRequestTime(requestPath) | |
| 361 | + | if errors.Is(err, os.ErrNotExist) { | |
| 362 | + | err = nil | |
| 363 | + | } else if err == nil { | |
| 364 | + | requestSeen = true | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | if requestSeen && err == nil { | |
| 368 | + | for runCtx.Err() == nil { | |
| 369 | + | err = copyNextFrame(res.Artifacts["frames"], requestedAt, capturePath) | |
| 370 | + | if err == nil { | |
| 371 | + | res.Artifacts["capture"] = capturePath | |
| 372 | + | break | |
| 373 | + | } | |
| 374 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 375 | + | break | |
| 376 | + | } | |
| 377 | + | err = nil | |
| 378 | + | select { | |
| 379 | + | case <-runCtx.Done(): | |
| 380 | + | case <-captureTick.C: | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | if res.Artifacts["capture"] == "" && err == nil { | |
| 384 | + | err = fmt.Errorf("capture-request: %w", runCtx.Err()) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | if err != nil { | |
| 389 | + | return fail(fmt.Errorf("client/compositor: %w", err), ProcessFailure) | |
| 390 | + | } | |
| 391 | + | select { | |
| 392 | + | case e := <-exited: | |
| 393 | + | return fail(fmt.Errorf("compositor exited during client: %v", e), ProcessFailure) | |
| 394 | + | default: | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | // Wait briefly for the asynchronous screenshot writer after the client exits. | |
| 398 | + | frameCtx, frameCancel := context.WithTimeout(runCtx, 3*time.Second) | |
| 399 | + | defer frameCancel() | |
| 400 | + | // A compositor crash after readiness is not a successful capture, even if latest.png exists. | |
| 401 | + | select { | |
| 402 | + | case e := <-exited: | |
| 403 | + | return fail(fmt.Errorf("compositor exited before capture: %v", e), ProcessFailure) | |
| 404 | + | default: | |
| 405 | + | } | |
| 406 | + | tick := time.NewTicker(25 * time.Millisecond) | |
| 407 | + | defer tick.Stop() | |
| 408 | + | for { | |
| 409 | + | _, e := LoadPNG(res.Artifacts["frame"]) | |
| 410 | + | if e == nil { | |
| 411 | + | break | |
| 412 | + | } | |
| 413 | + | select { | |
| 414 | + | case <-frameCtx.Done(): | |
| 415 | + | return fail(fmt.Errorf("no frame: %w", frameCtx.Err()), ProcessFailure) | |
| 416 | + | case e := <-exited: | |
| 417 | + | return fail(fmt.Errorf("compositor exited before frame: %v", e), ProcessFailure) | |
| 418 | + | case <-tick.C: | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | checkFrame := res.Artifacts["frame"] | |
| 422 | + | if res.Artifacts["capture"] != "" { | |
| 423 | + | checkFrame = res.Artifacts["capture"] | |
| 424 | + | } | |
| 425 | + | checks, err := Evaluate(checkFrame, o.Expect, o.GoldenBase, out) | |
| 426 | + | if err != nil { | |
| 427 | + | return fail(err, ProcessFailure) | |
| 428 | + | } | |
| 429 | + | res.Checks = checks | |
| 430 | + | for _, c := range checks { | |
| 431 | + | if !c.Pass { | |
| 432 | + | res.Status = "failed" | |
| 433 | + | return res, Failed | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | res.Status = "pass" | |
| 437 | + | return res, Pass | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // frameIndex accepts only compositor screenshots, not latest.png or temporary files. | |
| 441 | + | func frameIndex(name string) (int, bool) { | |
| 442 | + | if !strings.HasPrefix(name, "frame-") || !strings.HasSuffix(name, ".png") { | |
| 443 | + | return 0, false | |
| 444 | + | } | |
| 445 | + | digits := strings.TrimSuffix(strings.TrimPrefix(name, "frame-"), ".png") | |
| 446 | + | if len(digits) != 6 { | |
| 447 | + | return 0, false | |
| 448 | + | } | |
| 449 | + | for _, c := range digits { | |
| 450 | + | if c < '0' || c > '9' { | |
| 451 | + | return 0, false | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | n, err := strconv.Atoi(digits) | |
| 455 | + | return n, err == nil | |
| 456 | + | } | |
| 457 | + | func captureRequestTime(path string) (time.Time, error) { | |
| 458 | + | data, err := os.ReadFile(path) | |
| 459 | + | if err != nil { | |
| 460 | + | return time.Time{}, err | |
| 461 | + | } | |
| 462 | + | ns, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) | |
| 463 | + | if err != nil || ns <= 0 { | |
| 464 | + | return time.Time{}, fmt.Errorf("invalid capture-request timestamp %q", strings.TrimSpace(string(data))) | |
| 465 | + | } | |
| 466 | + | return time.Unix(0, ns), nil | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // nextFrame picks the second screenshot written after the commit token. | |
| 470 | + | // NeferWL renders and writes screenshots one after another, so the first file | |
| 471 | + | // finished after the token may have been rendered before it; the second began | |
| 472 | + | // after the first finished and therefore shows the committed frame. | |
| 473 | + | // An incomplete candidate is retried instead of selecting a later frame. | |
| 474 | + | func nextFrame(dir string, after time.Time) (string, error) { | |
| 475 | + | entries, err := os.ReadDir(dir) | |
| 476 | + | if err != nil { | |
| 477 | + | return "", err | |
| 478 | + | } | |
| 479 | + | first, second := int(^uint(0)>>1), int(^uint(0)>>1) | |
| 480 | + | names := map[int]string{} | |
| 481 | + | for _, entry := range entries { | |
| 482 | + | if entry.IsDir() { | |
| 483 | + | continue | |
| 484 | + | } | |
| 485 | + | n, ok := frameIndex(entry.Name()) | |
| 486 | + | if !ok { | |
| 487 | + | continue | |
| 488 | + | } | |
| 489 | + | info, e := entry.Info() | |
| 490 | + | if e != nil { | |
| 491 | + | return "", e | |
| 492 | + | } | |
| 493 | + | if !info.ModTime().After(after) { | |
| 494 | + | continue | |
| 495 | + | } | |
| 496 | + | names[n] = entry.Name() | |
| 497 | + | if n < first { | |
| 498 | + | first, second = n, first | |
| 499 | + | } else if n < second { | |
| 500 | + | second = n | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | return names[second], nil | |
| 504 | + | } | |
| 505 | + | func copyNextFrame(dir string, after time.Time, destination string) error { | |
| 506 | + | name, err := nextFrame(dir, after) | |
| 507 | + | if err != nil { | |
| 508 | + | return err | |
| 509 | + | } | |
| 510 | + | if name == "" { | |
| 511 | + | return os.ErrNotExist | |
| 512 | + | } | |
| 513 | + | // NeferWL writes frame-N directly. Retry an incomplete PNG on the next tick. | |
| 514 | + | path := filepath.Join(dir, name) | |
| 515 | + | if _, err := LoadPNG(path); err != nil { | |
| 516 | + | return os.ErrNotExist | |
| 517 | + | } | |
| 518 | + | data, err := os.ReadFile(path) | |
| 519 | + | if err != nil { | |
| 520 | + | return err | |
| 521 | + | } | |
| 522 | + | return os.WriteFile(destination, data, 0600) | |
| 523 | + | } | |
| 524 | + | func Evaluate(frame string, expect Expectations, base, out string) ([]Check, error) { | |
| 525 | + | img, err := LoadPNG(frame) | |
| 526 | + | if err != nil { | |
| 527 | + | return nil, err | |
| 528 | + | } | |
| 529 | + | checks := make([]Check, 0, len(expect.Probes)+len(expect.Regions)+len(expect.Goldens)) | |
| 530 | + | add := func(name string, err error) { | |
| 531 | + | c := Check{Name: name, Pass: err == nil} | |
| 532 | + | if err != nil { | |
| 533 | + | c.Detail = err.Error() | |
| 534 | + | } | |
| 535 | + | checks = append(checks, c) | |
| 536 | + | } | |
| 537 | + | for _, p := range expect.Probes { | |
| 538 | + | want, _ := ParseColor(p.Color) | |
| 539 | + | add(p.Name, PixelProbe(img, p.X, p.Y, want, p.Tolerance)) | |
| 540 | + | } | |
| 541 | + | for _, r := range expect.Regions { | |
| 542 | + | err := BoundsCheck(img, r.Rect) | |
| 543 | + | if err == nil && r.Color != "" { | |
| 544 | + | want, _ := ParseColor(r.Color) | |
| 545 | + | err = UniformRegion(img, r.Rect, want, r.Tolerance) | |
| 546 | + | } | |
| 547 | + | add(r.Name, err) | |
| 548 | + | } | |
| 549 | + | for i, g := range expect.Goldens { | |
| 550 | + | path := g.Path | |
| 551 | + | if !filepath.IsAbs(path) { | |
| 552 | + | path = filepath.Join(base, path) | |
| 553 | + | } | |
| 554 | + | ref, e := LoadPNG(path) | |
| 555 | + | if e != nil { | |
| 556 | + | add(g.Name, e) | |
| 557 | + | continue | |
| 558 | + | } | |
| 559 | + | pass, ratio, diff, e := CompareGolden(img, ref, g.Tolerance, g.MaxDiffRatio) | |
| 560 | + | if diff != nil { | |
| 561 | + | e = errors.Join(e, SavePNG(filepath.Join(out, fmt.Sprintf("diff-%d.png", i)), diff)) | |
| 562 | + | } | |
| 563 | + | if e == nil && !pass { | |
| 564 | + | e = fmt.Errorf("differing pixel ratio %.6f exceeds %.6f", ratio, g.MaxDiffRatio) | |
| 565 | + | } | |
| 566 | + | add(g.Name, e) | |
| 567 | + | } | |
| 568 | + | return checks, nil | |
| 569 | + | } | |
A internal/image/image.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // Package image provides bounded standard-library raster image ingestion. | |
| 2 | + | package image | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bytes" | |
| 6 | + | "errors" | |
| 7 | + | "image" | |
| 8 | + | _ "image/jpeg" | |
| 9 | + | _ "image/png" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // MaxPixels caps decoded pixel count, preventing unexpectedly large allocations. | |
| 13 | + | const MaxPixels = 16 * 1024 * 1024 | |
| 14 | + | ||
| 15 | + | var ErrSize = errors.New("image: invalid or excessive dimensions") | |
| 16 | + | ||
| 17 | + | // Size validates dimensions of an already decoded image. Its owner retains the image. | |
| 18 | + | func Size(src image.Image) (width, height int, err error) { | |
| 19 | + | if src == nil { | |
| 20 | + | return 0, 0, ErrSize | |
| 21 | + | } | |
| 22 | + | b := src.Bounds() | |
| 23 | + | w, h := b.Dx(), b.Dy() | |
| 24 | + | if w <= 0 || h <= 0 || w > MaxPixels/h { | |
| 25 | + | return 0, 0, ErrSize | |
| 26 | + | } | |
| 27 | + | return w, h, nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Decode accepts only PNG or JPEG bytes and checks dimensions before allocating pixels. | |
| 31 | + | // The caller bounds the input byte slice before reading it from an untrusted stream. | |
| 32 | + | func Decode(data []byte) (image.Image, error) { | |
| 33 | + | config, format, err := image.DecodeConfig(bytes.NewReader(data)) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, err | |
| 36 | + | } | |
| 37 | + | if format != "png" && format != "jpeg" { | |
| 38 | + | return nil, errors.New("image: unsupported format") | |
| 39 | + | } | |
| 40 | + | if config.Width <= 0 || config.Height <= 0 || config.Width > MaxPixels/config.Height { | |
| 41 | + | return nil, ErrSize | |
| 42 | + | } | |
| 43 | + | img, _, err := image.Decode(bytes.NewReader(data)) | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, err | |
| 46 | + | } | |
| 47 | + | if _, _, err = Size(img); err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | return img, nil | |
| 51 | + | } | |
A internal/image/image_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package image | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "hash/crc32" | |
| 7 | + | "image" | |
| 8 | + | "image/jpeg" | |
| 9 | + | "image/png" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestDecode(t *testing.T) { | |
| 14 | + | src := image.NewRGBA(image.Rect(3, 4, 5, 7)) | |
| 15 | + | for _, encode := range []func(*bytes.Buffer) error{ | |
| 16 | + | func(b *bytes.Buffer) error { return png.Encode(b, src) }, | |
| 17 | + | func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) }, | |
| 18 | + | } { | |
| 19 | + | var b bytes.Buffer | |
| 20 | + | if err := encode(&b); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | out, err := Decode(b.Bytes()) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | w, h, err := Size(out) | |
| 28 | + | if err != nil || w != 2 || h != 3 { | |
| 29 | + | t.Fatalf("%d %d %v", w, h, err) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | if _, err := Decode([]byte("bad")); err == nil { | |
| 33 | + | t.Fatal("invalid accepted") | |
| 34 | + | } | |
| 35 | + | var b bytes.Buffer | |
| 36 | + | if err := png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | data := b.Bytes() | |
| 40 | + | binary.BigEndian.PutUint32(data[16:], 100000) | |
| 41 | + | binary.BigEndian.PutUint32(data[20:], 100000) | |
| 42 | + | binary.BigEndian.PutUint32(data[29:], crc32.ChecksumIEEE(data[12:29])) | |
| 43 | + | if _, err := Decode(data); err != ErrSize { | |
| 44 | + | t.Fatalf("size: %v", err) | |
| 45 | + | } | |
| 46 | + | } | |
A internal/keyboard/keyboard.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | // Package keyboard interprets Wayland evdev key events on the UI loop. | |
| 2 | + | package keyboard | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | xkb "github.com/bnema/purego-xkbcommon" | |
| 10 | + | "github.com/bnema/purego-xkbcommon/raw" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type Key struct { | |
| 14 | + | Physical uint32 | |
| 15 | + | Logical uint32 | |
| 16 | + | Name, Text string | |
| 17 | + | Pressed, Repeat bool | |
| 18 | + | Shift, Ctrl bool | |
| 19 | + | Group uint32 | |
| 20 | + | } | |
| 21 | + | type Keyboard struct { | |
| 22 | + | context *xkb.Context | |
| 23 | + | keymap *xkb.Keymap | |
| 24 | + | state *xkb.State | |
| 25 | + | compose *xkb.ComposeState | |
| 26 | + | held map[uint32]Key | |
| 27 | + | rate int | |
| 28 | + | delay time.Duration | |
| 29 | + | next time.Time | |
| 30 | + | repeating uint32 | |
| 31 | + | focus bool | |
| 32 | + | composePending bool | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func New(locale string) (*Keyboard, error) { | |
| 36 | + | c, err := xkb.NewContext() | |
| 37 | + | if err != nil { | |
| 38 | + | return nil, err | |
| 39 | + | } | |
| 40 | + | k := &Keyboard{context: c, held: make(map[uint32]Key), focus: true} | |
| 41 | + | if locale != "" { | |
| 42 | + | t, e := c.NewComposeTable(locale) | |
| 43 | + | if e != nil { | |
| 44 | + | c.Close() | |
| 45 | + | return nil, e | |
| 46 | + | } | |
| 47 | + | k.compose, e = t.NewState() | |
| 48 | + | t.Close() | |
| 49 | + | if e != nil { | |
| 50 | + | c.Close() | |
| 51 | + | return nil, e | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return k, nil | |
| 55 | + | } | |
| 56 | + | func (k *Keyboard) Close() { | |
| 57 | + | k.Focus(false) | |
| 58 | + | if k.compose != nil { | |
| 59 | + | k.compose.Close() | |
| 60 | + | } | |
| 61 | + | if k.state != nil { | |
| 62 | + | k.state.Close() | |
| 63 | + | } | |
| 64 | + | if k.keymap != nil { | |
| 65 | + | k.keymap.Close() | |
| 66 | + | } | |
| 67 | + | if k.context != nil { | |
| 68 | + | k.context.Close() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // ReplaceFD consumes the received FD even on failure; the previous keymap remains valid on failure. | |
| 73 | + | func (k *Keyboard) ReplaceFD(fd, size int) error { | |
| 74 | + | if fd < 0 { | |
| 75 | + | return errors.New("keyboard: invalid fd") | |
| 76 | + | } | |
| 77 | + | file := os.NewFile(uintptr(fd), "keymap") | |
| 78 | + | if file == nil { | |
| 79 | + | return errors.New("keyboard: invalid fd") | |
| 80 | + | } | |
| 81 | + | defer file.Close() | |
| 82 | + | km, err := k.context.NewKeymapFD(fd, size) | |
| 83 | + | if err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | return k.replace(km) | |
| 87 | + | } | |
| 88 | + | func (k *Keyboard) Rules(layout, options string) error { | |
| 89 | + | km, err := k.context.NewKeymapRules("evdev", "pc105", layout, "", options) | |
| 90 | + | if err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | return k.replace(km) | |
| 94 | + | } | |
| 95 | + | func (k *Keyboard) replace(km *xkb.Keymap) error { | |
| 96 | + | s, err := km.NewState() | |
| 97 | + | if err != nil { | |
| 98 | + | km.Close() | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | k.Focus(false) | |
| 102 | + | if k.state != nil { | |
| 103 | + | k.state.Close() | |
| 104 | + | } | |
| 105 | + | if k.keymap != nil { | |
| 106 | + | k.keymap.Close() | |
| 107 | + | } | |
| 108 | + | k.keymap = km | |
| 109 | + | k.state = s | |
| 110 | + | k.Focus(true) | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | func (k *Keyboard) Mask(depressed, latched, locked, groupDepressed, groupLatched, groupLocked uint32) error { | |
| 114 | + | if k.state == nil { | |
| 115 | + | return errors.New("keyboard: no keymap") | |
| 116 | + | } | |
| 117 | + | _, err := k.state.UpdateMask(depressed, latched, locked, groupDepressed, groupLatched, groupLocked) | |
| 118 | + | return err | |
| 119 | + | } | |
| 120 | + | func (k *Keyboard) Focus(on bool) { | |
| 121 | + | k.focus = on | |
| 122 | + | k.held = make(map[uint32]Key) | |
| 123 | + | k.repeating = 0 | |
| 124 | + | if k.compose != nil { | |
| 125 | + | k.compose.Reset() | |
| 126 | + | } | |
| 127 | + | k.composePending = false | |
| 128 | + | } | |
| 129 | + | func (k *Keyboard) RepeatInfo(rate int, delay time.Duration) { | |
| 130 | + | k.rate = rate | |
| 131 | + | k.delay = delay | |
| 132 | + | if rate <= 0 { | |
| 133 | + | k.repeating = 0 | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) { | |
| 137 | + | if !pressed { | |
| 138 | + | key := k.held[evdev] | |
| 139 | + | delete(k.held, evdev) | |
| 140 | + | if k.repeating == evdev { | |
| 141 | + | k.repeating = 0 | |
| 142 | + | } | |
| 143 | + | key.Pressed = false | |
| 144 | + | key.Text = "" | |
| 145 | + | return key, nil | |
| 146 | + | } | |
| 147 | + | if !k.focus || k.state == nil { | |
| 148 | + | return Key{}, nil | |
| 149 | + | } | |
| 150 | + | code := xkb.WaylandKeycode(evdev) | |
| 151 | + | sym, err := k.state.KeySym(code) | |
| 152 | + | if err != nil { | |
| 153 | + | return Key{}, err | |
| 154 | + | } | |
| 155 | + | text, err := k.state.UTF8(code) | |
| 156 | + | if err != nil { | |
| 157 | + | return Key{}, err | |
| 158 | + | } | |
| 159 | + | name, _ := xkb.KeysymName(sym) | |
| 160 | + | group, _ := k.state.Layout() | |
| 161 | + | key := Key{Physical: evdev, Logical: sym, Name: name, Text: text, Pressed: true, Group: group} | |
| 162 | + | if key.Shift, key.Ctrl, err = k.modifiers(); err != nil { | |
| 163 | + | return Key{}, err | |
| 164 | + | } | |
| 165 | + | if k.compose != nil { | |
| 166 | + | if _, err = k.compose.Feed(sym); err != nil { | |
| 167 | + | return Key{}, err | |
| 168 | + | } | |
| 169 | + | status, _ := k.compose.Status() | |
| 170 | + | switch status { | |
| 171 | + | case raw.XKB_COMPOSE_COMPOSING, raw.XKB_COMPOSE_CANCELLED: | |
| 172 | + | key.Text = "" | |
| 173 | + | k.composePending = status == raw.XKB_COMPOSE_COMPOSING | |
| 174 | + | if status == raw.XKB_COMPOSE_CANCELLED { | |
| 175 | + | k.compose.Reset() | |
| 176 | + | } | |
| 177 | + | case raw.XKB_COMPOSE_COMPOSED: | |
| 178 | + | key.Text, err = k.compose.UTF8() | |
| 179 | + | k.compose.Reset() | |
| 180 | + | k.composePending = false | |
| 181 | + | if err != nil { | |
| 182 | + | return Key{}, err | |
| 183 | + | } | |
| 184 | + | default: | |
| 185 | + | k.composePending = false | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | k.held[evdev] = key | |
| 189 | + | if k.rate > 0 && repeatable(sym) { | |
| 190 | + | k.repeating = evdev | |
| 191 | + | k.next = now.Add(k.delay) | |
| 192 | + | } | |
| 193 | + | return key, nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Modifier, dead and compose keys never repeat. The compositor provides rate/delay; | |
| 197 | + | // its keymap is authoritative for text and layout. | |
| 198 | + | func repeatable(sym uint32) bool { | |
| 199 | + | switch sym { | |
| 200 | + | case raw.XKB_KEY_Shift_L, raw.XKB_KEY_Shift_R, raw.XKB_KEY_Control_L, raw.XKB_KEY_Control_R, | |
| 201 | + | raw.XKB_KEY_Alt_L, raw.XKB_KEY_Alt_R, raw.XKB_KEY_Super_L, raw.XKB_KEY_Super_R, | |
| 202 | + | raw.XKB_KEY_Caps_Lock, raw.XKB_KEY_Num_Lock, raw.XKB_KEY_Multi_key: | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | return sym < raw.XKB_KEY_dead_grave || sym > raw.XKB_KEY_dead_currency | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Tick returns at most one repeat for a due deadline, without accumulating missed ticks. | |
| 209 | + | // It resolves the held physical key against the current modifier/group mask. | |
| 210 | + | // Compose is fed only by physical presses: synthetic repeats neither advance a | |
| 211 | + | // compose sequence nor replay the press-time composed text. | |
| 212 | + | func (k *Keyboard) Tick(now time.Time) (Key, bool) { | |
| 213 | + | if !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | |
| 214 | + | return Key{}, false | |
| 215 | + | } | |
| 216 | + | physical := k.repeating | |
| 217 | + | if _, ok := k.held[physical]; !ok { | |
| 218 | + | return Key{}, false | |
| 219 | + | } | |
| 220 | + | code := xkb.WaylandKeycode(physical) | |
| 221 | + | sym, err := k.state.KeySym(code) | |
| 222 | + | if err != nil || !repeatable(sym) { | |
| 223 | + | k.repeating = 0 | |
| 224 | + | return Key{}, false | |
| 225 | + | } | |
| 226 | + | text, err := k.state.UTF8(code) | |
| 227 | + | if err != nil { | |
| 228 | + | k.repeating = 0 | |
| 229 | + | return Key{}, false | |
| 230 | + | } | |
| 231 | + | group, err := k.state.Layout() | |
| 232 | + | if err != nil { | |
| 233 | + | k.repeating = 0 | |
| 234 | + | return Key{}, false | |
| 235 | + | } | |
| 236 | + | name, _ := xkb.KeysymName(sym) | |
| 237 | + | if k.composePending { | |
| 238 | + | text = "" | |
| 239 | + | } | |
| 240 | + | key := Key{Physical: physical, Logical: sym, Name: name, Text: text, Pressed: true, Repeat: true, Group: group} | |
| 241 | + | // Repeats carry the current modifiers so e.g. held Shift+arrow keeps extending. | |
| 242 | + | if key.Shift, key.Ctrl, err = k.modifiers(); err != nil { | |
| 243 | + | k.repeating = 0 | |
| 244 | + | return Key{}, false | |
| 245 | + | } | |
| 246 | + | k.next = now.Add(time.Second / time.Duration(k.rate)) | |
| 247 | + | return key, true | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // modifiers reports whether Shift and Control are active in the current state. | |
| 251 | + | func (k *Keyboard) modifiers() (shift, ctrl bool, err error) { | |
| 252 | + | mods, err := k.state.Mods() | |
| 253 | + | if err != nil { | |
| 254 | + | return false, false, err | |
| 255 | + | } | |
| 256 | + | if idx, e := k.keymap.ModIndex("Shift"); e == nil && idx < 32 { | |
| 257 | + | shift = mods&(1<<idx) != 0 | |
| 258 | + | } | |
| 259 | + | if idx, e := k.keymap.ModIndex("Control"); e == nil && idx < 32 { | |
| 260 | + | ctrl = mods&(1<<idx) != 0 | |
| 261 | + | } | |
| 262 | + | return shift, ctrl, nil | |
| 263 | + | } | |
| 264 | + | func (k *Keyboard) NextRepeat() (time.Time, bool) { return k.next, k.repeating != 0 } | |
A internal/keyboard/keyboard_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | package keyboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/purego-xkbcommon/raw" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestLayoutsAndRepeat(t *testing.T) { | |
| 12 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 13 | + | defer k.Close() | |
| 14 | + | now := time.Now() | |
| 15 | + | for _, tc := range []struct { | |
| 16 | + | layout string | |
| 17 | + | code uint32 | |
| 18 | + | want string | |
| 19 | + | }{{"us", 16, "q"}, {"fr", 16, "a"}, {"de", 21, "z"}} { | |
| 20 | + | if err := k.Rules(tc.layout, ""); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | key, err := k.Event(tc.code, true, now) | |
| 24 | + | if err != nil || key.Text != tc.want || key.Physical != tc.code || key.Logical == 0 { | |
| 25 | + | t.Fatalf("%s: %+v %v", tc.layout, key, err) | |
| 26 | + | } | |
| 27 | + | k.Event(tc.code, false, now) | |
| 28 | + | } | |
| 29 | + | if err := k.Rules("de", ""); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | idx, err := k.keymap.ModIndex("Mod5") | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | key, err := k.Event(18, true, now) | |
| 40 | + | if err != nil || key.Text != "€" { | |
| 41 | + | t.Fatalf("AltGr: %+v %v", key, err) | |
| 42 | + | } | |
| 43 | + | k.Event(18, false, now) | |
| 44 | + | k.Mask(0, 0, 0, 0, 0, 0) | |
| 45 | + | k.RepeatInfo(20, 300*time.Millisecond) | |
| 46 | + | k.Event(16, true, now) | |
| 47 | + | if _, ok := k.Tick(now.Add(299 * time.Millisecond)); ok { | |
| 48 | + | t.Fatal("early repeat") | |
| 49 | + | } | |
| 50 | + | if _, ok := k.Tick(now.Add(300 * time.Millisecond)); !ok { | |
| 51 | + | t.Fatal("missing repeat") | |
| 52 | + | } | |
| 53 | + | k.Focus(false) | |
| 54 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 55 | + | t.Fatal("repeat after focus loss") | |
| 56 | + | } | |
| 57 | + | k.Focus(true) | |
| 58 | + | k.Event(16, true, now) | |
| 59 | + | k.Event(16, false, now) | |
| 60 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 61 | + | t.Fatal("repeat after release") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | func TestComposeAndGroups(t *testing.T) { | |
| 65 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 66 | + | var err error | |
| 67 | + | defer k.Close() | |
| 68 | + | if err = k.Rules("de", ""); err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | now := time.Now() | |
| 72 | + | dead, err := k.Event(41, true, now) | |
| 73 | + | if err != nil || dead.Logical != raw.XKB_KEY_dead_circumflex || dead.Text != "" { | |
| 74 | + | t.Fatalf("dead: %+v %v", dead, err) | |
| 75 | + | } | |
| 76 | + | k.Event(41, false, now) | |
| 77 | + | composed, err := k.Event(18, true, now) | |
| 78 | + | if err != nil || composed.Text != "ê" { | |
| 79 | + | t.Fatalf("compose: %+v %v", composed, err) | |
| 80 | + | } | |
| 81 | + | if err = k.Rules("us,fr", ""); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if err = k.Mask(0, 0, 0, 0, 0, 1); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | grouped, err := k.Event(16, true, now) | |
| 88 | + | if err != nil || grouped.Text != "a" || grouped.Group != 1 { | |
| 89 | + | t.Fatalf("group: %+v %v", grouped, err) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestKeymapFDReplacement(t *testing.T) { | |
| 94 | + | k := requiredKeyboard(t, "") | |
| 95 | + | var err error | |
| 96 | + | defer k.Close() | |
| 97 | + | if err = k.Rules("us", ""); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | // A malformed regular file must be consumed without discarding the old map. | |
| 101 | + | f, err := os.CreateTemp(t.TempDir(), "bad-keymap") | |
| 102 | + | if err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | if _, err = f.WriteString("bad keymap"); err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | if err = k.ReplaceFD(int(f.Fd()), len("bad keymap")); err == nil { | |
| 109 | + | t.Fatal("bad keymap accepted") | |
| 110 | + | } | |
| 111 | + | if _, err = f.Stat(); err == nil { | |
| 112 | + | t.Fatal("keymap FD not closed") | |
| 113 | + | } | |
| 114 | + | if err = k.ReplaceFD(-1, 0); err == nil { | |
| 115 | + | t.Fatal("invalid fd accepted") | |
| 116 | + | } | |
| 117 | + | key, err := k.Event(16, true, time.Now()) | |
| 118 | + | if err != nil || key.Text != "q" { | |
| 119 | + | t.Fatalf("old map lost: %+v %v", key, err) | |
| 120 | + | } | |
| 121 | + | k.Event(16, false, time.Now()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestValidFDReplacementCancelsRepeat(t *testing.T) { | |
| 125 | + | k := requiredKeyboard(t, "") | |
| 126 | + | defer k.Close() | |
| 127 | + | if err := k.Rules("us", ""); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | now := time.Now() | |
| 131 | + | k.RepeatInfo(20, 100*time.Millisecond) | |
| 132 | + | press, err := k.Event(16, true, now) | |
| 133 | + | if err != nil || press.Text != "q" { | |
| 134 | + | t.Fatalf("old map press: %+v %v", press, err) | |
| 135 | + | } | |
| 136 | + | data, err := os.ReadFile("testdata/fr.xkb") | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | file, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if _, err = file.Write(append(data, 0)); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | if err = k.ReplaceFD(int(file.Fd()), len(data)+1); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | if _, err = file.Stat(); err == nil { | |
| 151 | + | t.Fatal("FD not consumed") | |
| 152 | + | } | |
| 153 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 154 | + | t.Fatal("repeat survived keymap replacement") | |
| 155 | + | } | |
| 156 | + | if _, ok := k.NextRepeat(); ok { | |
| 157 | + | t.Fatal("repeat deadline survived replacement") | |
| 158 | + | } | |
| 159 | + | key, err := k.Event(16, true, now) | |
| 160 | + | if err != nil || key.Text != "a" { | |
| 161 | + | t.Fatalf("new map press: %+v %v", key, err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Required offline gate: only explicitly opted-out hosts may skip libxkbcommon. | |
| 166 | + | func requiredKeyboard(t *testing.T, locale string) *Keyboard { | |
| 167 | + | t.Helper() | |
| 168 | + | k, err := New(locale) | |
| 169 | + | if err != nil { | |
| 170 | + | if os.Getenv("NEFERGUI_SKIP_XKB") == "1" { | |
| 171 | + | t.Skipf("xkb unavailable: %v", err) | |
| 172 | + | } | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | return k | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestRepeatTracksCurrentMaskAndCompose(t *testing.T) { | |
| 179 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 180 | + | defer k.Close() | |
| 181 | + | if err := k.Rules("us,fr", ""); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | now := time.Now() | |
| 185 | + | k.RepeatInfo(10, 100*time.Millisecond) | |
| 186 | + | press, err := k.Event(16, true, now) | |
| 187 | + | if err != nil || press.Text != "q" { | |
| 188 | + | t.Fatalf("press: %+v %v", press, err) | |
| 189 | + | } | |
| 190 | + | shift, err := k.keymap.ModIndex("Shift") | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | if err = k.Mask(1<<shift, 0, 0, 0, 0, 0); err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | rep, ok := k.Tick(now.Add(100 * time.Millisecond)) | |
| 198 | + | if !ok || rep.Text != "Q" || rep.Logical != raw.XKB_KEY_Q || !rep.Repeat || !rep.Shift || rep.Ctrl { | |
| 199 | + | t.Fatalf("shift repeat: %+v %t", rep, ok) | |
| 200 | + | } | |
| 201 | + | if err = k.Mask(0, 0, 0, 0, 0, 1); err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | rep, ok = k.Tick(now.Add(200 * time.Millisecond)) | |
| 205 | + | if !ok || rep.Text != "a" || rep.Group != 1 { | |
| 206 | + | t.Fatalf("layout repeat: %+v %t", rep, ok) | |
| 207 | + | } | |
| 208 | + | k.Focus(false) | |
| 209 | + | k.Focus(true) | |
| 210 | + | if err = k.Rules("de", ""); err != nil { | |
| 211 | + | t.Fatal(err) | |
| 212 | + | } | |
| 213 | + | k.Event(41, true, now) | |
| 214 | + | k.Event(41, false, now) | |
| 215 | + | // A repeat must not replay the composed press-time character. | |
| 216 | + | composed, err := k.Event(18, true, now) | |
| 217 | + | if err != nil || composed.Text != "ê" { | |
| 218 | + | t.Fatalf("compose press: %+v %v", composed, err) | |
| 219 | + | } | |
| 220 | + | rep, ok = k.Tick(now.Add(100 * time.Millisecond)) | |
| 221 | + | if !ok || rep.Text != "e" { | |
| 222 | + | t.Fatalf("post-compose repeat: %+v %t", rep, ok) | |
| 223 | + | } | |
| 224 | + | } | |
A internal/keyboard/testdata/fr.xkb +2084 -0 large diff (2084 lines changed): collapsed
A internal/layout/layout.go +916 -0
| @@ -0,0 +1,916 @@ | |||
| 1 | + | // Package layout computes logical-pixel boxes and an ordered, serializable paint list. | |
| 2 | + | // Input nodes and CSS styles are borrowed for one synchronous Layout call. | |
| 3 | + | package layout | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "image" | |
| 8 | + | "math" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefergui/internal/css" | |
| 11 | + | "github.com/bnema/nefergui/internal/text" | |
| 12 | + | "github.com/go-text/typesetting/di" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Kind selects intrinsic and container behavior. CSS display:none suppresses all kinds; | |
| 16 | + | // display:flex takes precedence over Kind, and row/column flex defaults are overridable. | |
| 17 | + | type Kind string | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | Box Kind = "box" | |
| 21 | + | Row Kind = "row" | |
| 22 | + | Column Kind = "column" | |
| 23 | + | Stack Kind = "stack" | |
| 24 | + | Scroll Kind = "scroll" | |
| 25 | + | Text Kind = "text" | |
| 26 | + | Image Kind = "image" | |
| 27 | + | ) | |
| 28 | + | const limit = 1 << 24 | |
| 29 | + | ||
| 30 | + | // Rect uses logical pixels, including fractional positions. | |
| 31 | + | type Rect struct{ X, Y, W, H float64 } | |
| 32 | + | ||
| 33 | + | func (r Rect) Contains(x, y float64) bool { return x >= r.X && y >= r.Y && x < r.X+r.W && y < r.Y+r.H } | |
| 34 | + | func (r Rect) intersect(q Rect) Rect { | |
| 35 | + | x := math.Max(r.X, q.X) | |
| 36 | + | y := math.Max(r.Y, q.Y) | |
| 37 | + | return Rect{x, y, math.Max(0, math.Min(r.X+r.W, q.X+q.W)-x), math.Max(0, math.Min(r.Y+r.H, q.Y+q.H)-y)} | |
| 38 | + | } | |
| 39 | + | func safe(v float64) float64 { | |
| 40 | + | if math.IsNaN(v) || v < 0 { | |
| 41 | + | return 0 | |
| 42 | + | } | |
| 43 | + | if math.IsInf(v, 0) || v > limit { | |
| 44 | + | return limit | |
| 45 | + | } | |
| 46 | + | return v | |
| 47 | + | } | |
| 48 | + | func coord(v float64) float64 { | |
| 49 | + | if math.IsNaN(v) { | |
| 50 | + | return 0 | |
| 51 | + | } | |
| 52 | + | if math.IsInf(v, 1) { | |
| 53 | + | return limit | |
| 54 | + | } | |
| 55 | + | if math.IsInf(v, -1) { | |
| 56 | + | return -limit | |
| 57 | + | } | |
| 58 | + | return math.Max(-limit, math.Min(limit, v)) | |
| 59 | + | } | |
| 60 | + | func length(l css.Length, basis float64) (float64, bool) { | |
| 61 | + | switch l.Unit { | |
| 62 | + | case "px": | |
| 63 | + | return coord(l.Value), true | |
| 64 | + | case "%": | |
| 65 | + | return coord(safe(basis) * l.Value / 100), true | |
| 66 | + | } | |
| 67 | + | return 0, false | |
| 68 | + | } | |
| 69 | + | func positive(l css.Length, basis float64) float64 { v, _ := length(l, basis); return safe(v) } | |
| 70 | + | func edges(s css.LengthSides, basis float64) Edges { | |
| 71 | + | return Edges{coordValue(s.Top, basis), coordValue(s.Right, basis), coordValue(s.Bottom, basis), coordValue(s.Left, basis)} | |
| 72 | + | } | |
| 73 | + | func coordValue(l css.Length, basis float64) float64 { v, _ := length(l, basis); return v } | |
| 74 | + | ||
| 75 | + | // Edges are in top/right/bottom/left order. | |
| 76 | + | type Edges struct{ Top, Right, Bottom, Left float64 } | |
| 77 | + | ||
| 78 | + | func (e Edges) horizontal() float64 { return e.Left + e.Right } | |
| 79 | + | func (e Edges) vertical() float64 { return e.Top + e.Bottom } | |
| 80 | + | func nonnegative(e Edges) Edges { | |
| 81 | + | return Edges{safe(e.Top), safe(e.Right), safe(e.Bottom), safe(e.Left)} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Measure receives the available content width (zero means unbounded), and must | |
| 85 | + | // return finite logical dimensions and optionally shaped lines. It may return an error. | |
| 86 | + | // TextEngine, when supplied, is used for Text nodes without an explicit callback. | |
| 87 | + | type Measure func(width float64) (Size, []text.Line, error) | |
| 88 | + | type Size struct{ W, H float64 } | |
| 89 | + | ||
| 90 | + | // Node has no parent pointers or renderer resources. ID identifies hit targets and | |
| 91 | + | // display commands; callers must supply stable, unique IDs if they need identity. | |
| 92 | + | // ImageSize is the decoded image's intrinsic size; image ownership remains with caller. | |
| 93 | + | // Image is borrowed through painting. Treat it as immutable while passed to Image: | |
| 94 | + | // pass a new image value to change pixels. Renderer cache identity requires a | |
| 95 | + | // comparable image.Image interface value (for example, *image.RGBA). | |
| 96 | + | type Node struct { | |
| 97 | + | ID string | |
| 98 | + | Kind Kind | |
| 99 | + | Style *css.Style | |
| 100 | + | Children []*Node | |
| 101 | + | Content string | |
| 102 | + | ImageSize Size | |
| 103 | + | Image image.Image `json:"-"` | |
| 104 | + | Measure Measure `json:"-"` | |
| 105 | + | ScrollX, ScrollY float64 | |
| 106 | + | NoWrap bool // measure text at unbounded width; useful for single-line editors | |
| 107 | + | EditorScroll bool // clip and clamp own text on both axes, without child scrolling | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Result is a detached layout snapshot. Style is deliberately not retained. | |
| 111 | + | type Result struct { | |
| 112 | + | ID string | |
| 113 | + | Kind Kind | |
| 114 | + | Border, Content, Clip Rect | |
| 115 | + | Margin, Padding, BorderWidths Edges | |
| 116 | + | ContentSize Size | |
| 117 | + | ScrollX, ScrollY float64 | |
| 118 | + | Lines []text.Line `json:"-"` | |
| 119 | + | Children []*Result | |
| 120 | + | Clipped bool | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Command is a paint operation. Text line ranges refer to rune offsets | |
| 124 | + | // in Text; image pixels are borrowed and excluded from serialized display lists. | |
| 125 | + | type Command struct { | |
| 126 | + | Op string | |
| 127 | + | ID string | |
| 128 | + | Rect Rect | |
| 129 | + | Color css.Color | |
| 130 | + | Opacity float64 | |
| 131 | + | Widths Edges | |
| 132 | + | Colors css.ColorSides | |
| 133 | + | Radii [4]float64 | |
| 134 | + | Shadow *css.Shadow `json:",omitempty"` | |
| 135 | + | Text string `json:",omitempty"` | |
| 136 | + | Runs []Run `json:",omitempty"` | |
| 137 | + | Image image.Image `json:"-"` | |
| 138 | + | } | |
| 139 | + | type Run struct { | |
| 140 | + | Start, End int | |
| 141 | + | X, Y, Advance float64 | |
| 142 | + | // Face is borrowed for in-process rasterization. FaceID and Size preserve | |
| 143 | + | // the shaping identity and logical pixel size in serialized display lists. | |
| 144 | + | Face *text.Face `json:"-"` | |
| 145 | + | FaceID string | |
| 146 | + | Size float64 | |
| 147 | + | Glyphs []Glyph | |
| 148 | + | } | |
| 149 | + | type Glyph struct { | |
| 150 | + | ID uint32 | |
| 151 | + | X, Y, Advance float64 | |
| 152 | + | Cluster int | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // Output contains a JSON-safe tree and paint list in back-to-front command order. | |
| 156 | + | type Output struct { | |
| 157 | + | Tree *Result | |
| 158 | + | Display []Command | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Options provide logical viewport size and an optional text shaping engine. | |
| 162 | + | type Options struct { | |
| 163 | + | Width, Height float64 | |
| 164 | + | TextEngine *text.Engine | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | var ErrDepth = errors.New("layout: tree depth exceeds 256") | |
| 168 | + | ||
| 169 | + | // Layout does not mutate inputs. The caller must not create cycles in the node tree. | |
| 170 | + | func Layout(root *Node, options Options) (Output, error) { | |
| 171 | + | if root == nil { | |
| 172 | + | return Output{}, nil | |
| 173 | + | } | |
| 174 | + | ctx := context{options: options} | |
| 175 | + | viewport := Rect{0, 0, safe(options.Width), safe(options.Height)} | |
| 176 | + | tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0) | |
| 177 | + | if err != nil { | |
| 178 | + | return Output{}, err | |
| 179 | + | } | |
| 180 | + | out := Output{Tree: tree} | |
| 181 | + | ctx.paint(root, tree, &out.Display) | |
| 182 | + | return out, nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | type context struct{ options Options } | |
| 186 | + | ||
| 187 | + | func style(n *Node) css.Style { | |
| 188 | + | if n.Style != nil { | |
| 189 | + | return *n.Style | |
| 190 | + | } | |
| 191 | + | return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1} | |
| 192 | + | } | |
| 193 | + | func bounded(v float64, minL, maxL css.Length, basis float64) float64 { | |
| 194 | + | v = safe(v) | |
| 195 | + | if min, ok := length(minL, basis); ok { | |
| 196 | + | v = math.Max(v, safe(min)) | |
| 197 | + | } | |
| 198 | + | if max, ok := length(maxL, basis); ok { | |
| 199 | + | v = math.Min(v, safe(max)) | |
| 200 | + | } | |
| 201 | + | return safe(v) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // boxBound applies constraints to the CSS sizing space, not to the outer box. | |
| 205 | + | func boxBound(border float64, minL, maxL css.Length, basis, insets float64, borderBox bool) float64 { | |
| 206 | + | if borderBox { | |
| 207 | + | return math.Max(insets, bounded(border, minL, maxL, basis)) | |
| 208 | + | } | |
| 209 | + | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | |
| 210 | + | } | |
| 211 | + | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |
| 212 | + | if n.Measure != nil { | |
| 213 | + | s, lines, err := n.Measure(w) | |
| 214 | + | if err != nil { | |
| 215 | + | return Size{}, nil, err | |
| 216 | + | } | |
| 217 | + | for _, line := range lines { | |
| 218 | + | if math.IsNaN(line.Width) || math.IsInf(line.Width, 0) || math.IsNaN(line.Height) || math.IsInf(line.Height, 0) { | |
| 219 | + | return Size{}, nil, errors.New("layout: invalid measured text") | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | return Size{safe(s.W), safe(s.H)}, lines, nil | |
| 223 | + | } | |
| 224 | + | if n.Kind == Image { | |
| 225 | + | return Size{safe(n.ImageSize.W), safe(n.ImageSize.H)}, nil, nil | |
| 226 | + | } | |
| 227 | + | if n.Kind == Text && c.options.TextEngine != nil { | |
| 228 | + | st := style(n) | |
| 229 | + | req := text.Request{Families: st.FontFamily, Weight: float32(st.FontWeight), Italic: st.FontStyle == css.KeywordItalic || st.FontStyle == css.KeywordOblique, Size: safe(st.FontSize.Value), LetterSpacing: coordValue(st.LetterSpacing, 0)} | |
| 230 | + | if req.Size == 0 { | |
| 231 | + | req.Size = 16 | |
| 232 | + | } | |
| 233 | + | l, err := c.options.TextEngine.Measure(n.Content, req, w) | |
| 234 | + | if err != nil { | |
| 235 | + | return Size{}, nil, err | |
| 236 | + | } | |
| 237 | + | lineHeight := l.LineHeight | |
| 238 | + | switch st.LineHeightKind { | |
| 239 | + | case css.KeywordNumber: | |
| 240 | + | lineHeight = safe(req.Size * st.LineHeightNumber) | |
| 241 | + | case css.KeywordNormal: | |
| 242 | + | default: | |
| 243 | + | if st.LineHeight.Unit != "auto" && st.LineHeight.Unit != "" { | |
| 244 | + | lineHeight = positive(st.LineHeight, req.Size) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | // Keep the glyphs' shaped positions; adjust baseline and line origins, not outlines. | |
| 248 | + | if lineHeight != l.LineHeight && len(l.Lines) > 0 { | |
| 249 | + | l = l.Clone() // Measure results are shared with the engine cache | |
| 250 | + | for i := range l.Lines { | |
| 251 | + | line := &l.Lines[i] | |
| 252 | + | dy := float64(i)*(lineHeight-l.LineHeight) + (lineHeight-l.LineHeight)/2 | |
| 253 | + | line.Baseline += dy | |
| 254 | + | line.Height = lineHeight | |
| 255 | + | for j := range line.Runs { | |
| 256 | + | run := &line.Runs[j] | |
| 257 | + | run.Y += dy | |
| 258 | + | for k := range run.Glyphs { | |
| 259 | + | run.Glyphs[k].Y += dy | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | l.Height = float64(len(l.Lines)) * lineHeight | |
| 264 | + | } | |
| 265 | + | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | |
| 266 | + | } | |
| 267 | + | return Size{}, nil, nil | |
| 268 | + | } | |
| 269 | + | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | |
| 270 | + | if depth > 256 { | |
| 271 | + | return nil, ErrDepth | |
| 272 | + | } | |
| 273 | + | if n == nil { | |
| 274 | + | return nil, nil | |
| 275 | + | } | |
| 276 | + | s := style(n) | |
| 277 | + | if s.Display == css.KeywordNone { | |
| 278 | + | return nil, nil | |
| 279 | + | } | |
| 280 | + | availableW = safe(availableW) | |
| 281 | + | availableH = safe(availableH) | |
| 282 | + | margin := edges(s.Margin, availableW) | |
| 283 | + | padding := nonnegative(edges(s.Padding, availableW)) | |
| 284 | + | border := nonnegative(edges(s.BorderWidth, availableW)) | |
| 285 | + | if s.BorderStyle.Top != css.KeywordSolid { | |
| 286 | + | border.Top = 0 | |
| 287 | + | } | |
| 288 | + | if s.BorderStyle.Right != css.KeywordSolid { | |
| 289 | + | border.Right = 0 | |
| 290 | + | } | |
| 291 | + | if s.BorderStyle.Bottom != css.KeywordSolid { | |
| 292 | + | border.Bottom = 0 | |
| 293 | + | } | |
| 294 | + | if s.BorderStyle.Left != css.KeywordSolid { | |
| 295 | + | border.Left = 0 | |
| 296 | + | } | |
| 297 | + | outerW := safe(availableW - margin.horizontal()) | |
| 298 | + | insetW := padding.horizontal() + border.horizontal() | |
| 299 | + | insetH := padding.vertical() + border.vertical() | |
| 300 | + | w, explicitW := length(s.Width, availableW) | |
| 301 | + | h, explicitH := length(s.Height, availableH) | |
| 302 | + | borderBox := s.BoxSizing == css.KeywordBorderBox | |
| 303 | + | // Forced flex lengths are border-box; authored dimensions and constraints use box-sizing. | |
| 304 | + | if forcedW != nil { | |
| 305 | + | w, explicitW = *forcedW, true | |
| 306 | + | } else if explicitW { | |
| 307 | + | w = bounded(w, s.MinWidth, s.MaxWidth, availableW) | |
| 308 | + | if !borderBox { | |
| 309 | + | w += insetW | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | if forcedH != nil { | |
| 313 | + | h, explicitH = *forcedH, true | |
| 314 | + | } else if explicitH { | |
| 315 | + | h = bounded(h, s.MinHeight, s.MaxHeight, availableH) | |
| 316 | + | if !borderBox { | |
| 317 | + | h += insetH | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | if !explicitW { | |
| 321 | + | w = outerW | |
| 322 | + | if n.Kind == Image { | |
| 323 | + | w = 0 | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | if !explicitW && n.Kind != Image { | |
| 327 | + | w = boxBound(w, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox) | |
| 328 | + | } | |
| 329 | + | w = safe(math.Max(w, insetW)) | |
| 330 | + | contentW := safe(w - insetW) | |
| 331 | + | measureW := contentW | |
| 332 | + | if n.Kind == Text && n.NoWrap { | |
| 333 | + | measureW = 0 | |
| 334 | + | } | |
| 335 | + | measured, lines, err := c.intrinsic(n, measureW) | |
| 336 | + | if err != nil { | |
| 337 | + | return nil, err | |
| 338 | + | } | |
| 339 | + | if !explicitW && n.Kind == Image { | |
| 340 | + | w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox) | |
| 341 | + | contentW = safe(w - insetW) | |
| 342 | + | measured, lines, err = c.intrinsic(n, contentW) | |
| 343 | + | if err != nil { | |
| 344 | + | return nil, err | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | if !explicitH { | |
| 348 | + | h = measured.H + insetH | |
| 349 | + | if n.Kind == Image && measured.W > 0 && explicitW { | |
| 350 | + | h = measured.H*contentW/measured.W + insetH | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | if n.Kind == Image && !explicitW && explicitH && measured.H > 0 { | |
| 354 | + | w = boxBound(measured.W*safe(h-insetH)/measured.H+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox) | |
| 355 | + | contentW = safe(w - insetW) | |
| 356 | + | } | |
| 357 | + | h = safe(math.Max(h, insetH)) | |
| 358 | + | if !explicitH { | |
| 359 | + | h = boxBound(h, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |
| 360 | + | } | |
| 361 | + | r := &Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines} | |
| 362 | + | r.Content = Rect{coord(r.Border.X + border.Left + padding.Left), coord(r.Border.Y + border.Top + padding.Top), contentW, safe(h - insetH)} | |
| 363 | + | r.ContentSize = Size{measured.W, measured.H} | |
| 364 | + | // Block and stack auto heights depend on their children's envelopes. | |
| 365 | + | flex := s.Display == css.KeywordFlex | |
| 366 | + | if flex { | |
| 367 | + | err = c.flex(n, r, s, depth) | |
| 368 | + | } else { | |
| 369 | + | err = c.children(n, r, s, depth) | |
| 370 | + | } | |
| 371 | + | if err != nil { | |
| 372 | + | return nil, err | |
| 373 | + | } | |
| 374 | + | if !explicitH { | |
| 375 | + | r.Border.H = boxBound(math.Max(safe(h-insetH), r.ContentSize.H)+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |
| 376 | + | r.Content.H = safe(r.Border.H - insetH) | |
| 377 | + | } | |
| 378 | + | clipX := overflowClips(s.OverflowX) || n.Kind == Scroll || n.EditorScroll | |
| 379 | + | clipY := overflowClips(s.OverflowY) || n.Kind == Scroll || n.EditorScroll | |
| 380 | + | if clipX || clipY { | |
| 381 | + | r.Clipped = true | |
| 382 | + | r.Clip = r.Content | |
| 383 | + | if !clipX { | |
| 384 | + | r.Clip.X = -limit | |
| 385 | + | r.Clip.W = 2 * limit | |
| 386 | + | } | |
| 387 | + | if !clipY { | |
| 388 | + | r.Clip.Y = -limit | |
| 389 | + | r.Clip.H = 2 * limit | |
| 390 | + | } | |
| 391 | + | if clipX && (s.OverflowX == css.KeywordScroll || s.OverflowX == css.KeywordAuto || n.Kind == Scroll || n.EditorScroll) { | |
| 392 | + | r.ScrollX = math.Min(safe(n.ScrollX), safe(r.ContentSize.W-r.Content.W)) | |
| 393 | + | } | |
| 394 | + | if clipY && (s.OverflowY == css.KeywordScroll || s.OverflowY == css.KeywordAuto || n.Kind == Scroll || n.EditorScroll) { | |
| 395 | + | r.ScrollY = math.Min(safe(n.ScrollY), safe(r.ContentSize.H-r.Content.H)) | |
| 396 | + | } | |
| 397 | + | for _, child := range r.Children { | |
| 398 | + | shift(child, -r.ScrollX, -r.ScrollY) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | return r, nil | |
| 402 | + | } | |
| 403 | + | func overflowClips(k css.Keyword) bool { | |
| 404 | + | return k == css.KeywordHidden || k == css.KeywordScroll || k == css.KeywordAuto | |
| 405 | + | } | |
| 406 | + | func shift(r *Result, dx, dy float64) { | |
| 407 | + | r.Border.X = coord(r.Border.X + dx) | |
| 408 | + | r.Border.Y = coord(r.Border.Y + dy) | |
| 409 | + | r.Content.X = coord(r.Content.X + dx) | |
| 410 | + | r.Content.Y = coord(r.Content.Y + dy) | |
| 411 | + | r.Clip.X = coord(r.Clip.X + dx) | |
| 412 | + | r.Clip.Y = coord(r.Clip.Y + dy) | |
| 413 | + | for _, child := range r.Children { | |
| 414 | + | shift(child, dx, dy) | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | func envelope(r *Result) (float64, float64) { | |
| 418 | + | return safe(r.Border.W + r.Margin.horizontal()), safe(r.Border.H + r.Margin.vertical()) | |
| 419 | + | } | |
| 420 | + | func (c context) children(n *Node, r *Result, s css.Style, depth int) error { | |
| 421 | + | y := r.Content.Y | |
| 422 | + | for _, ch := range n.Children { | |
| 423 | + | if ch == nil { | |
| 424 | + | continue | |
| 425 | + | } | |
| 426 | + | child, err := c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1) | |
| 427 | + | if err != nil { | |
| 428 | + | return err | |
| 429 | + | } | |
| 430 | + | if child == nil { | |
| 431 | + | continue | |
| 432 | + | } | |
| 433 | + | cw, chh := envelope(child) | |
| 434 | + | r.Children = append(r.Children, child) | |
| 435 | + | r.ContentSize.W = math.Max(r.ContentSize.W, cw) | |
| 436 | + | if n.Kind == Stack { | |
| 437 | + | r.ContentSize.H = math.Max(r.ContentSize.H, chh) | |
| 438 | + | } else { | |
| 439 | + | y = coord(y + chh) | |
| 440 | + | r.ContentSize.H = math.Max(r.ContentSize.H, y-r.Content.Y) | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | return nil | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | type flexItem struct { | |
| 447 | + | node *Node | |
| 448 | + | base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes | |
| 449 | + | min, max float64 | |
| 450 | + | frozen bool | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // flexInsets computes effective border-box insets, using the same border-style | |
| 454 | + | // treatment as place. Percentage padding is relative to the containing width. | |
| 455 | + | func flexInsets(s css.Style, basis float64) (float64, float64) { | |
| 456 | + | p := nonnegative(edges(s.Padding, basis)) | |
| 457 | + | b := nonnegative(edges(s.BorderWidth, basis)) | |
| 458 | + | if s.BorderStyle.Top != css.KeywordSolid { | |
| 459 | + | b.Top = 0 | |
| 460 | + | } | |
| 461 | + | if s.BorderStyle.Right != css.KeywordSolid { | |
| 462 | + | b.Right = 0 | |
| 463 | + | } | |
| 464 | + | if s.BorderStyle.Bottom != css.KeywordSolid { | |
| 465 | + | b.Bottom = 0 | |
| 466 | + | } | |
| 467 | + | if s.BorderStyle.Left != css.KeywordSolid { | |
| 468 | + | b.Left = 0 | |
| 469 | + | } | |
| 470 | + | return p.horizontal() + b.horizontal(), p.vertical() + b.vertical() | |
| 471 | + | } | |
| 472 | + | func flexBounds(s css.Style, main bool, basis, inset float64) (float64, float64) { | |
| 473 | + | minL, maxL := s.MinWidth, s.MaxWidth | |
| 474 | + | if !main { | |
| 475 | + | minL, maxL = s.MinHeight, s.MaxHeight | |
| 476 | + | } | |
| 477 | + | low, high := inset, float64(limit) | |
| 478 | + | if v, ok := length(minL, basis); ok { | |
| 479 | + | if s.BoxSizing != css.KeywordBorderBox { | |
| 480 | + | low = math.Max(inset, safe(v)+inset) | |
| 481 | + | } else { | |
| 482 | + | low = math.Max(inset, safe(v)) | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | if v, ok := length(maxL, basis); ok { | |
| 486 | + | high = safe(v) | |
| 487 | + | if s.BoxSizing != css.KeywordBorderBox { | |
| 488 | + | high += inset | |
| 489 | + | } | |
| 490 | + | high = math.Max(high, inset) | |
| 491 | + | } | |
| 492 | + | return low, math.Max(low, high) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // resolveFlex freezes violating items, then redistributes the remaining free | |
| 496 | + | // space among unfrozen items. Shrink weights always use the original base. | |
| 497 | + | func resolveFlex(items []flexItem, available float64) { | |
| 498 | + | total := 0.0 | |
| 499 | + | for i := range items { | |
| 500 | + | it := &items[i] | |
| 501 | + | it.size = it.base | |
| 502 | + | it.frozen = false | |
| 503 | + | total += it.base + it.marginMain | |
| 504 | + | } | |
| 505 | + | grow := available > total | |
| 506 | + | // Each pass freezes at least one violator, or all remaining items. | |
| 507 | + | for range items { | |
| 508 | + | free, weight, factors := available, 0.0, 0.0 | |
| 509 | + | for i := range items { | |
| 510 | + | it := &items[i] | |
| 511 | + | free -= it.marginMain | |
| 512 | + | if it.frozen { | |
| 513 | + | free -= it.size | |
| 514 | + | continue | |
| 515 | + | } | |
| 516 | + | free -= it.base | |
| 517 | + | factor := it.grow | |
| 518 | + | if !grow { | |
| 519 | + | factor = it.shrink * it.base | |
| 520 | + | } | |
| 521 | + | weight += factor | |
| 522 | + | if grow { | |
| 523 | + | factors += it.grow | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | // CSS resolve-flexible-lengths limits growth when total grow factors < 1. | |
| 527 | + | if grow && factors < 1 { | |
| 528 | + | free *= factors | |
| 529 | + | } | |
| 530 | + | violation := 0.0 | |
| 531 | + | for i := range items { | |
| 532 | + | it := &items[i] | |
| 533 | + | if it.frozen { | |
| 534 | + | continue | |
| 535 | + | } | |
| 536 | + | factor := it.grow | |
| 537 | + | if !grow { | |
| 538 | + | factor = it.shrink * it.base | |
| 539 | + | } | |
| 540 | + | tentative := it.base | |
| 541 | + | if weight > 0 { | |
| 542 | + | tentative += free * factor / weight | |
| 543 | + | } | |
| 544 | + | it.size = math.Max(it.min, math.Min(it.max, safe(tentative))) | |
| 545 | + | violation += it.size - tentative | |
| 546 | + | } | |
| 547 | + | if math.Abs(violation) < 1e-9 { | |
| 548 | + | for i := range items { | |
| 549 | + | items[i].frozen = true | |
| 550 | + | } | |
| 551 | + | break | |
| 552 | + | } | |
| 553 | + | for i := range items { | |
| 554 | + | it := &items[i] | |
| 555 | + | if it.frozen { | |
| 556 | + | continue | |
| 557 | + | } | |
| 558 | + | factor := it.grow | |
| 559 | + | if !grow { | |
| 560 | + | factor = it.shrink * it.base | |
| 561 | + | } | |
| 562 | + | tentative := it.base | |
| 563 | + | if weight > 0 { | |
| 564 | + | tentative += free * factor / weight | |
| 565 | + | } | |
| 566 | + | if violation > 0 && it.size > tentative || violation < 0 && it.size < tentative { | |
| 567 | + | it.frozen = true | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |
| 573 | + | row := s.FlexDirection != css.KeywordColumn | |
| 574 | + | mainSize, crossSize := r.Content.W, r.Content.H | |
| 575 | + | gap, crossGap := positive(s.ColumnGap, r.Content.W), positive(s.RowGap, r.Content.W) | |
| 576 | + | if !row { | |
| 577 | + | mainSize, crossSize = crossSize, mainSize | |
| 578 | + | gap, crossGap = crossGap, gap | |
| 579 | + | } | |
| 580 | + | items := make([]flexItem, 0, len(n.Children)) | |
| 581 | + | for _, ch := range n.Children { | |
| 582 | + | if ch == nil || style(ch).Display == css.KeywordNone { | |
| 583 | + | continue | |
| 584 | + | } | |
| 585 | + | st := style(ch) | |
| 586 | + | m := edges(st.Margin, r.Content.W) | |
| 587 | + | inW, inH := flexInsets(st, r.Content.W) | |
| 588 | + | inMain, inCross := inW, inH | |
| 589 | + | if !row { | |
| 590 | + | inMain, inCross = inH, inW | |
| 591 | + | } | |
| 592 | + | it := flexItem{node: ch, grow: safe(st.FlexGrow), shrink: safe(st.FlexShrink)} | |
| 593 | + | if row { | |
| 594 | + | it.marginMain = m.horizontal() | |
| 595 | + | it.marginCross = m.vertical() | |
| 596 | + | } else { | |
| 597 | + | it.marginMain = m.vertical() | |
| 598 | + | it.marginCross = m.horizontal() | |
| 599 | + | } | |
| 600 | + | dim, crossDim := st.Width, st.Height | |
| 601 | + | minCross, maxCross := st.MinHeight, st.MaxHeight | |
| 602 | + | if !row { | |
| 603 | + | dim, crossDim = st.Height, st.Width | |
| 604 | + | minCross, maxCross = st.MinWidth, st.MaxWidth | |
| 605 | + | } | |
| 606 | + | measureWidth := 0.0 | |
| 607 | + | if row { | |
| 608 | + | measureWidth = mainSize | |
| 609 | + | } else { | |
| 610 | + | measureWidth = crossSize | |
| 611 | + | } | |
| 612 | + | measured, _, err := c.intrinsic(ch, measureWidth) | |
| 613 | + | if err != nil { | |
| 614 | + | return err | |
| 615 | + | } | |
| 616 | + | intrinsicMain, intrinsicCross := measured.W, measured.H | |
| 617 | + | if !row { | |
| 618 | + | intrinsicMain, intrinsicCross = measured.H, measured.W | |
| 619 | + | } | |
| 620 | + | value, ok := length(st.FlexBasis, mainSize) | |
| 621 | + | if !ok { | |
| 622 | + | value, ok = length(dim, mainSize) | |
| 623 | + | } | |
| 624 | + | if ok { | |
| 625 | + | it.base = safe(value) | |
| 626 | + | if st.BoxSizing != css.KeywordBorderBox { | |
| 627 | + | it.base += inMain | |
| 628 | + | } | |
| 629 | + | } else { | |
| 630 | + | it.base = intrinsicMain + inMain | |
| 631 | + | } | |
| 632 | + | value, ok = length(crossDim, crossSize) | |
| 633 | + | if ok { | |
| 634 | + | it.cross = safe(value) | |
| 635 | + | if st.BoxSizing != css.KeywordBorderBox { | |
| 636 | + | it.cross += inCross | |
| 637 | + | } | |
| 638 | + | } else { | |
| 639 | + | it.cross = intrinsicCross + inCross | |
| 640 | + | } | |
| 641 | + | it.min, it.max = flexBounds(st, row, mainSize, inMain) | |
| 642 | + | it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox) | |
| 643 | + | items = append(items, it) | |
| 644 | + | } | |
| 645 | + | lines := [][]flexItem{} | |
| 646 | + | line := []flexItem{} | |
| 647 | + | used := 0.0 | |
| 648 | + | for _, item := range items { | |
| 649 | + | need := item.base + item.marginMain | |
| 650 | + | if len(line) > 0 { | |
| 651 | + | need += gap | |
| 652 | + | } | |
| 653 | + | if s.FlexWrap == css.KeywordWrap && len(line) > 0 && used+need > mainSize { | |
| 654 | + | lines = append(lines, line) | |
| 655 | + | line = nil | |
| 656 | + | used = 0 | |
| 657 | + | need = item.base + item.marginMain | |
| 658 | + | } | |
| 659 | + | line = append(line, item) | |
| 660 | + | used += need | |
| 661 | + | } | |
| 662 | + | if len(line) > 0 { | |
| 663 | + | lines = append(lines, line) | |
| 664 | + | } | |
| 665 | + | crossPos := 0.0 | |
| 666 | + | for _, line := range lines { | |
| 667 | + | lineCross := 0.0 | |
| 668 | + | for _, it := range line { | |
| 669 | + | lineCross = math.Max(lineCross, it.cross+it.marginCross) | |
| 670 | + | } | |
| 671 | + | if len(lines) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") { | |
| 672 | + | lineCross = math.Max(lineCross, crossSize) | |
| 673 | + | } | |
| 674 | + | resolveFlex(line, mainSize-gap*float64(len(line)-1)) | |
| 675 | + | occupied := gap * float64(len(line)-1) | |
| 676 | + | for _, it := range line { | |
| 677 | + | occupied += it.size + it.marginMain | |
| 678 | + | } | |
| 679 | + | remaining := safe(mainSize - occupied) | |
| 680 | + | start, spacing := 0.0, gap | |
| 681 | + | switch s.JustifyContent { | |
| 682 | + | case css.KeywordFlexEnd: | |
| 683 | + | start = remaining | |
| 684 | + | case css.KeywordCenter: | |
| 685 | + | start = remaining / 2 | |
| 686 | + | case css.KeywordSpaceBetween: | |
| 687 | + | if len(line) > 1 { | |
| 688 | + | spacing += remaining / float64(len(line)-1) | |
| 689 | + | } | |
| 690 | + | case css.KeywordSpaceAround: | |
| 691 | + | spacing += remaining / float64(len(line)) | |
| 692 | + | start = remaining / float64(2*len(line)) | |
| 693 | + | case css.KeywordSpaceEvenly: | |
| 694 | + | spacing += remaining / float64(len(line)+1) | |
| 695 | + | start = remaining / float64(len(line)+1) | |
| 696 | + | } | |
| 697 | + | pos := start | |
| 698 | + | for _, it := range line { | |
| 699 | + | align := style(it.node).AlignSelf | |
| 700 | + | if align == css.KeywordAuto || align == css.KeywordNone { | |
| 701 | + | align = s.AlignItems | |
| 702 | + | } | |
| 703 | + | cross := it.cross | |
| 704 | + | if align == css.KeywordStretch { | |
| 705 | + | cross = safe(lineCross - it.marginCross) | |
| 706 | + | } | |
| 707 | + | offset := 0.0 | |
| 708 | + | switch align { | |
| 709 | + | case css.KeywordCenter: | |
| 710 | + | offset = safe(lineCross-cross-it.marginCross) / 2 | |
| 711 | + | case css.KeywordFlexEnd: | |
| 712 | + | offset = safe(lineCross - cross - it.marginCross) | |
| 713 | + | } | |
| 714 | + | main := safe(it.size) | |
| 715 | + | cross = safe(cross) | |
| 716 | + | var x, y float64 | |
| 717 | + | var w, h *float64 | |
| 718 | + | if row { | |
| 719 | + | x = r.Content.X + pos | |
| 720 | + | y = r.Content.Y + crossPos + offset | |
| 721 | + | w = &main | |
| 722 | + | h = &cross | |
| 723 | + | } else { | |
| 724 | + | x = r.Content.X + crossPos + offset | |
| 725 | + | y = r.Content.Y + pos | |
| 726 | + | w = &cross | |
| 727 | + | h = &main | |
| 728 | + | } | |
| 729 | + | child, err := c.place(it.node, x, y, r.Content.W, r.Content.H, w, h, depth+1) | |
| 730 | + | if err != nil { | |
| 731 | + | return err | |
| 732 | + | } | |
| 733 | + | if child != nil { | |
| 734 | + | r.Children = append(r.Children, child) | |
| 735 | + | cw, ch := envelope(child) | |
| 736 | + | r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw) | |
| 737 | + | r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch) | |
| 738 | + | } | |
| 739 | + | pos += main + it.marginMain + spacing | |
| 740 | + | } | |
| 741 | + | crossPos += lineCross + crossGap | |
| 742 | + | } | |
| 743 | + | return nil | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | func (c context) paint(n *Node, r *Result, out *[]Command) { | |
| 747 | + | if r == nil { | |
| 748 | + | return | |
| 749 | + | } | |
| 750 | + | s := style(n) | |
| 751 | + | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | |
| 752 | + | for i := range s.BoxShadow { | |
| 753 | + | sh := s.BoxShadow[i] | |
| 754 | + | if sh.Inset { | |
| 755 | + | continue | |
| 756 | + | } | |
| 757 | + | cmd := base | |
| 758 | + | cmd.Op = "shadow" | |
| 759 | + | cmd.Shadow = &sh | |
| 760 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 761 | + | *out = append(*out, cmd) | |
| 762 | + | } | |
| 763 | + | if s.BackgroundColor.A > 0 { | |
| 764 | + | cmd := base | |
| 765 | + | cmd.Op = "rect" | |
| 766 | + | cmd.Color = s.BackgroundColor | |
| 767 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 768 | + | *out = append(*out, cmd) | |
| 769 | + | } | |
| 770 | + | cmd := base | |
| 771 | + | cmd.Op = "border" | |
| 772 | + | cmd.Widths = r.BorderWidths | |
| 773 | + | cmd.Colors = s.BorderColor | |
| 774 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 775 | + | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | |
| 776 | + | cmd.Color = s.BorderColor.Top | |
| 777 | + | *out = append(*out, cmd) | |
| 778 | + | } | |
| 779 | + | for i := range s.BoxShadow { | |
| 780 | + | sh := s.BoxShadow[i] | |
| 781 | + | if !sh.Inset { | |
| 782 | + | continue | |
| 783 | + | } | |
| 784 | + | cmd := base | |
| 785 | + | cmd.Op = "shadow" | |
| 786 | + | cmd.Shadow = &sh | |
| 787 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 788 | + | *out = append(*out, cmd) | |
| 789 | + | } | |
| 790 | + | if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 { | |
| 791 | + | cmd = base | |
| 792 | + | cmd.Op = "outline" | |
| 793 | + | cmd.Color = s.OutlineColor | |
| 794 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 795 | + | cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)} | |
| 796 | + | offset := coordValue(s.OutlineOffset, r.Border.W) | |
| 797 | + | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | |
| 798 | + | *out = append(*out, cmd) | |
| 799 | + | } | |
| 800 | + | if r.Clipped { | |
| 801 | + | cmd = base | |
| 802 | + | cmd.Op = "clip-push" | |
| 803 | + | cmd.Rect = r.Clip | |
| 804 | + | *out = append(*out, cmd) | |
| 805 | + | } | |
| 806 | + | if n.Kind == Image { | |
| 807 | + | cmd = base | |
| 808 | + | cmd.Op = "image" | |
| 809 | + | cmd.Rect = r.Content | |
| 810 | + | cmd.Image = n.Image | |
| 811 | + | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)} | |
| 812 | + | *out = append(*out, cmd) | |
| 813 | + | } | |
| 814 | + | if n.Kind == Text { | |
| 815 | + | cmd = base | |
| 816 | + | cmd.Op = "text" | |
| 817 | + | cmd.Rect = r.Content | |
| 818 | + | cmd.Text = n.Content | |
| 819 | + | cmd.Color = s.Color | |
| 820 | + | for _, line := range r.Lines { | |
| 821 | + | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | |
| 822 | + | for _, run := range line.Runs { | |
| 823 | + | rr := Run{Start: run.Start, End: run.End, X: run.X + r.Content.X + offset - r.ScrollX, Y: run.Y + r.Content.Y - r.ScrollY, Advance: run.Advance, Face: run.Face, Size: s.FontSize.Value} | |
| 824 | + | if rr.Size <= 0 { | |
| 825 | + | rr.Size = 16 // matches the intrinsic text.Measure request fallback | |
| 826 | + | } | |
| 827 | + | if run.Face != nil { | |
| 828 | + | rr.FaceID = run.Face.ID | |
| 829 | + | } | |
| 830 | + | for _, g := range run.Glyphs { | |
| 831 | + | rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |
| 832 | + | } | |
| 833 | + | cmd.Runs = append(cmd.Runs, rr) | |
| 834 | + | } | |
| 835 | + | } | |
| 836 | + | *out = append(*out, cmd) | |
| 837 | + | } | |
| 838 | + | j := 0 | |
| 839 | + | for _, child := range n.Children { | |
| 840 | + | if child == nil || style(child).Display == css.KeywordNone { | |
| 841 | + | continue | |
| 842 | + | } | |
| 843 | + | if j < len(r.Children) { | |
| 844 | + | c.paint(child, r.Children[j], out) | |
| 845 | + | j++ | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | if r.Clipped { | |
| 849 | + | cmd = base | |
| 850 | + | cmd.Op = "clip-pop" | |
| 851 | + | *out = append(*out, cmd) | |
| 852 | + | } | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | // AlignOffset is the horizontal offset of a line of width lineW inside a | |
| 856 | + | // content box of width contentW under text-align and the line's direction. | |
| 857 | + | func AlignOffset(align css.Keyword, direction di.Direction, contentW, lineW float64) float64 { | |
| 858 | + | switch align { | |
| 859 | + | case css.KeywordCenter: | |
| 860 | + | return safe(contentW-lineW) / 2 | |
| 861 | + | case css.KeywordRight: | |
| 862 | + | return safe(contentW - lineW) | |
| 863 | + | case css.KeywordStart: | |
| 864 | + | if direction == di.DirectionRTL { | |
| 865 | + | return safe(contentW - lineW) | |
| 866 | + | } | |
| 867 | + | case css.KeywordEnd: | |
| 868 | + | if direction != di.DirectionRTL { | |
| 869 | + | return safe(contentW - lineW) | |
| 870 | + | } | |
| 871 | + | } | |
| 872 | + | return 0 | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // Hit returns the frontmost node ID whose border box contains the logical point. | |
| 876 | + | // Ancestor content clips constrain descendants, but not the ancestor border itself. | |
| 877 | + | func Hit(r *Result, x, y float64) string { | |
| 878 | + | if math.IsNaN(x) || math.IsNaN(y) || math.IsInf(x, 0) || math.IsInf(y, 0) { | |
| 879 | + | return "" | |
| 880 | + | } | |
| 881 | + | return hit(r, x, y, Rect{-limit, -limit, 2 * limit, 2 * limit}) | |
| 882 | + | } | |
| 883 | + | func hit(r *Result, x, y float64, clip Rect) string { | |
| 884 | + | if r == nil || !clip.Contains(x, y) { | |
| 885 | + | return "" | |
| 886 | + | } | |
| 887 | + | childClip := clip | |
| 888 | + | if r.Clipped { | |
| 889 | + | childClip = clip.intersect(r.Clip) | |
| 890 | + | } | |
| 891 | + | if childClip.Contains(x, y) { | |
| 892 | + | for i := len(r.Children) - 1; i >= 0; i-- { | |
| 893 | + | if id := hit(r.Children[i], x, y, childClip); id != "" { | |
| 894 | + | return id | |
| 895 | + | } | |
| 896 | + | } | |
| 897 | + | } | |
| 898 | + | if r.Border.Contains(x, y) { | |
| 899 | + | return r.ID | |
| 900 | + | } | |
| 901 | + | return "" | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | // Physical is the explicit renderer boundary; all layout and hit testing stay logical. | |
| 905 | + | // It rounds edges independently to avoid accumulated fractional-scale drift. | |
| 906 | + | func Physical(r Rect, scale float64) (x, y, w, h int) { | |
| 907 | + | if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 { | |
| 908 | + | return | |
| 909 | + | } | |
| 910 | + | scale = math.Min(scale, 1024) | |
| 911 | + | x = int(math.Round(coord(r.X) * scale)) | |
| 912 | + | y = int(math.Round(coord(r.Y) * scale)) | |
| 913 | + | w = int(math.Round(coord(r.X+safe(r.W))*scale)) - x | |
| 914 | + | h = int(math.Round(coord(r.Y+safe(r.H))*scale)) - y | |
| 915 | + | return | |
| 916 | + | } | |
A internal/layout/layout_test.go +545 -0
| @@ -0,0 +1,545 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "image" | |
| 6 | + | "math" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "reflect" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefergui/internal/css" | |
| 13 | + | "github.com/bnema/nefergui/internal/text" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func px(v float64) css.Length { return css.Length{Value: v, Unit: "px"} } | |
| 17 | + | func node(id string, k Kind, s css.Style, children ...*Node) *Node { | |
| 18 | + | return &Node{ID: id, Kind: k, Style: &s, Children: children} | |
| 19 | + | } | |
| 20 | + | func snapshot(t *testing.T, name string, out Output) { | |
| 21 | + | t.Helper() | |
| 22 | + | got, err := json.MarshalIndent(out, "", " ") | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | path := filepath.Join("testdata", name+".json") | |
| 27 | + | if os.Getenv("NEFERGUI_UPDATE_SNAPSHOTS") == "1" { | |
| 28 | + | if err := os.MkdirAll("testdata", 0755); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | if err := os.WriteFile(path, append(got, '\n'), 0644); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | want, err := os.ReadFile(path) | |
| 36 | + | if os.IsNotExist(err) { | |
| 37 | + | t.Fatalf("snapshot missing %s; computed: %s", path, got) | |
| 38 | + | } | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | var a, b any | |
| 43 | + | if err = json.Unmarshal(got, &a); err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | if err = json.Unmarshal(want, &b); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | if !reflect.DeepEqual(a, b) { | |
| 50 | + | t.Fatalf("snapshot %s differs:\n%s", name, got) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | func TestNestedSnapshot(t *testing.T) { | |
| 54 | + | e := css.Compile(css.UA(), css.Parse(`box {padding: 4px; border: 2px solid red; background: blue} image {width:50%;height:20px;margin: 3px}`)) | |
| 55 | + | root := e.Compute(&css.Element{Type: "box"}, nil) | |
| 56 | + | child := e.Compute(&css.Element{Type: "image"}, root) | |
| 57 | + | out, err := Layout(node("root", Box, *root.Style, node("pic", Image, *child.Style)), Options{Width: 200, Height: 100}) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if out.Tree.Content.X != 6 || out.Tree.Children[0].Border.W != 94 { | |
| 62 | + | t.Fatalf("unexpected box: %+v", out.Tree) | |
| 63 | + | } | |
| 64 | + | snapshot(t, "nested", out) | |
| 65 | + | } | |
| 66 | + | func TestFlex(t *testing.T) { | |
| 67 | + | s := css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordRow, ColumnGap: px(10), FlexWrap: css.KeywordWrap} | |
| 68 | + | child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(12), FlexGrow: 1, FlexShrink: 1} | |
| 69 | + | root := node("r", Row, s, node("a", Box, child), node("b", Box, child), node("c", Box, child)) | |
| 70 | + | out, err := Layout(root, Options{Width: 120, Height: 50}) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if len(out.Tree.Children) != 3 || out.Tree.Children[0].Border.W != 55 || out.Tree.Children[1].Border.X != 65 || out.Tree.Children[2].Border.Y != 12 { | |
| 75 | + | t.Fatalf("flex wrap: %+v", out.Tree.Children) | |
| 76 | + | } | |
| 77 | + | snapshot(t, "flex", out) | |
| 78 | + | s.FlexWrap = css.KeywordNowrap | |
| 79 | + | root.Style = &s | |
| 80 | + | out, err = Layout(root, Options{Width: 120, Height: 50}) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if math.Abs(out.Tree.Children[0].Border.W-100.0/3) > 1e-6 { | |
| 85 | + | t.Fatalf("shrink: %v", out.Tree.Children[0].Border.W) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | func TestStackScrollHit(t *testing.T) { | |
| 89 | + | child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(50), BackgroundColor: css.Color{R: 1, A: 1}} | |
| 90 | + | s := css.Style{Display: css.KeywordBlock, Width: px(30), Height: px(30), OverflowX: css.KeywordHidden, OverflowY: css.KeywordScroll} | |
| 91 | + | root := node("scroll", Scroll, s, node("stack", Stack, css.Style{Display: css.KeywordBlock}, node("back", Box, child), node("front", Box, child))) | |
| 92 | + | out, err := Layout(root, Options{Width: 100, Height: 100}) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | if Hit(out.Tree, 10, 10) != "front" || Hit(out.Tree, 45, 10) == "front" { | |
| 97 | + | t.Fatal("paint order/clip", out.Tree) | |
| 98 | + | } | |
| 99 | + | root.ScrollY = 10 | |
| 100 | + | out, err = Layout(root, Options{Width: 100, Height: 100}) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if out.Tree.ScrollY != 10 || out.Tree.Children[0].Border.Y != -10 { | |
| 105 | + | t.Fatalf("scroll: %+v", out.Tree) | |
| 106 | + | } | |
| 107 | + | snapshot(t, "scroll", out) | |
| 108 | + | } | |
| 109 | + | func TestTextAndLimits(t *testing.T) { | |
| 110 | + | m := func(w float64) (Size, []text.Line, error) { | |
| 111 | + | if w > 20 { | |
| 112 | + | return Size{W: 20, H: 10}, nil, nil | |
| 113 | + | } | |
| 114 | + | return Size{W: 20, H: 30}, nil, nil | |
| 115 | + | } | |
| 116 | + | s := css.Style{Display: css.KeywordBlock, Width: px(15), Padding: css.LengthSides{Left: px(2), Right: px(2)}} | |
| 117 | + | n := node("text", Text, s) | |
| 118 | + | n.Content = "three lines" | |
| 119 | + | n.Measure = m | |
| 120 | + | out, err := Layout(n, Options{Width: 50, Height: 100}) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | if out.Tree.Border.H != 30 || out.Tree.Content.W != 15 { | |
| 125 | + | t.Fatalf("wrap: %+v", out.Tree) | |
| 126 | + | } | |
| 127 | + | s.Width = px(math.NaN()) | |
| 128 | + | s.Height = px(math.Inf(1)) | |
| 129 | + | n.Style = &s | |
| 130 | + | out, err = Layout(n, Options{Width: math.Inf(1), Height: math.NaN()}) | |
| 131 | + | if err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | if _, err = json.Marshal(out); err != nil { | |
| 135 | + | t.Fatal("non-finite output", err) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | func TestScale(t *testing.T) { | |
| 139 | + | for _, v := range []struct { | |
| 140 | + | scale float64 | |
| 141 | + | x, w int | |
| 142 | + | }{{1, 1, 3}, {1.25, 1, 4}, {1.5, 2, 4}, {2, 2, 6}} { | |
| 143 | + | x, _, w, _ := Physical(Rect{X: 1, Y: 0, W: 3, H: 4}, v.scale) | |
| 144 | + | if x != v.x || w != v.w { | |
| 145 | + | t.Fatalf("scale %v: %d,%d", v.scale, x, w) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | func TestCSSMalformed(t *testing.T) { | |
| 150 | + | sheet := css.Parse(`box {width: nope; padding: 5px; border: bad} image {height: 10px}`) | |
| 151 | + | if len(sheet.Diagnostics) == 0 { | |
| 152 | + | t.Fatal("missing diagnostics") | |
| 153 | + | } | |
| 154 | + | e := css.Compile(css.UA(), sheet) | |
| 155 | + | root := e.Compute(&css.Element{Type: "box"}, nil) | |
| 156 | + | pic := e.Compute(&css.Element{Type: "image"}, root) | |
| 157 | + | out, err := Layout(node("r", Box, *root.Style, node("i", Image, *pic.Style)), Options{Width: 50, Height: 50}) | |
| 158 | + | if err != nil || out.Tree.Children[0].Border.H != 10 { | |
| 159 | + | t.Fatalf("malformed CSS: %+v %v", out, err) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | func FuzzLayout(f *testing.F) { | |
| 163 | + | f.Add(float64(1), float64(10), float64(100)) | |
| 164 | + | f.Add(math.NaN(), math.Inf(1), float64(-1)) | |
| 165 | + | f.Fuzz(func(t *testing.T, w, p, g float64) { | |
| 166 | + | s := css.Style{Display: css.KeywordFlex, Width: px(w), Padding: css.LengthSides{Left: px(p)}, ColumnGap: px(g)} | |
| 167 | + | n := node("r", Row, s, node("child", Image, css.Style{Display: css.KeywordBlock}, nil)) | |
| 168 | + | out, err := Layout(n, Options{Width: 200, Height: 100}) | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatal(err) | |
| 171 | + | } | |
| 172 | + | if _, err = json.Marshal(out); err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | }) | |
| 176 | + | } | |
| 177 | + | func BenchmarkLayout1000(b *testing.B) { | |
| 178 | + | children := make([]*Node, 1000) | |
| 179 | + | s := css.Style{Display: css.KeywordBlock, Height: px(2)} | |
| 180 | + | for i := range children { | |
| 181 | + | children[i] = node("child", Box, s) | |
| 182 | + | } | |
| 183 | + | root := node("root", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, children...) | |
| 184 | + | b.ReportAllocs() | |
| 185 | + | for i := 0; i < b.N; i++ { | |
| 186 | + | if _, err := Layout(root, Options{Width: 600, Height: 2200}); err != nil { | |
| 187 | + | b.Fatal(err) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestTextEngineWrap(t *testing.T) { | |
| 193 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | engine := text.NewEngine(catalog) | |
| 198 | + | s := css.Compile(css.UA(), css.Parse("text {width: 60px; font-size: 16px}")).Compute(&css.Element{Type: "text"}, nil).Style | |
| 199 | + | n := node("words", Text, *s) | |
| 200 | + | n.Content = "alpha beta gamma delta" | |
| 201 | + | out, err := Layout(n, Options{Width: 200, Height: 200, TextEngine: engine}) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatal(err) | |
| 204 | + | } | |
| 205 | + | if len(out.Tree.Lines) < 2 || out.Tree.Content.H <= 16 { | |
| 206 | + | t.Fatalf("text did not wrap: %+v", out.Tree) | |
| 207 | + | } | |
| 208 | + | found := false | |
| 209 | + | for _, cmd := range out.Display { | |
| 210 | + | if cmd.Op == "text" { | |
| 211 | + | found = len(cmd.Runs) > 0 | |
| 212 | + | for _, run := range cmd.Runs { | |
| 213 | + | if run.Face == nil || run.FaceID != run.Face.ID || run.Size != 16 { | |
| 214 | + | t.Fatalf("missing raster identity: %+v", run) | |
| 215 | + | } | |
| 216 | + | data, err := json.Marshal(run) | |
| 217 | + | if err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | var encoded map[string]any | |
| 221 | + | if err := json.Unmarshal(data, &encoded); err != nil { | |
| 222 | + | t.Fatal(err) | |
| 223 | + | } | |
| 224 | + | if _, leaked := encoded["Face"]; leaked || encoded["FaceID"] != run.FaceID || encoded["Size"] != 16.0 { | |
| 225 | + | t.Fatalf("serialized raster identity: %s", data) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | if !found { | |
| 231 | + | t.Fatal("missing glyph runs") | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestPaintStylesAndFlexDefaults(t *testing.T) { | |
| 236 | + | en := css.Compile(css.UA(), css.Parse(`row {height: 24px; align-items: center; gap: 4px} box { width: 12px; height: 4px; background: red; border: 1px solid green; border-radius: 3px; outline: 2px solid blue; box-shadow: 1px 2px 2px black}`)) | |
| 237 | + | parent := en.Compute(&css.Element{Type: "row"}, nil) | |
| 238 | + | item := en.Compute(&css.Element{Type: "box"}, parent) | |
| 239 | + | out, err := Layout(node("row", Row, *parent.Style, node("item", Box, *item.Style)), Options{Width: 100, Height: 50}) | |
| 240 | + | if err != nil { | |
| 241 | + | t.Fatal(err) | |
| 242 | + | } | |
| 243 | + | if out.Tree.Children[0].Border.Y != 9 { | |
| 244 | + | t.Fatalf("align center: %v", out.Tree.Children[0].Border.Y) | |
| 245 | + | } | |
| 246 | + | ops := []string{} | |
| 247 | + | for _, cmd := range out.Display { | |
| 248 | + | ops = append(ops, cmd.Op) | |
| 249 | + | if cmd.Op == "border" && (cmd.Colors.Top.G != 128.0/255 || cmd.Radii[0] != 3) { | |
| 250 | + | t.Fatalf("border: %+v", cmd) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | if !reflect.DeepEqual(ops, []string{"shadow", "rect", "border", "outline"}) { | |
| 254 | + | t.Fatal(ops) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | func TestFlexOverridesAndConstraints(t *testing.T) { | |
| 258 | + | e := css.Compile(css.UA(), css.Parse(`row {display:block} box {width:50%; min-width: 60px; max-width:80px; height:10px; box-sizing:border-box; padding:5px}`)) | |
| 259 | + | p := e.Compute(&css.Element{Type: "row"}, nil) | |
| 260 | + | ch := e.Compute(&css.Element{Type: "box"}, p) | |
| 261 | + | out, err := Layout(node("r", Row, *p.Style, node("one", Box, *ch.Style), node("two", Box, *ch.Style)), Options{Width: 100, Height: 100}) | |
| 262 | + | if err != nil { | |
| 263 | + | t.Fatal(err) | |
| 264 | + | } | |
| 265 | + | if out.Tree.Children[0].Border.W != 60 || out.Tree.Children[1].Border.Y != 10 { | |
| 266 | + | t.Fatalf("override constraints: %+v", out.Tree.Children) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestIntrinsicImageRatio(t *testing.T) { | |
| 271 | + | n := node("img", Image, css.Style{Display: css.KeywordBlock, Width: px(80)}) | |
| 272 | + | n.ImageSize = Size{W: 40, H: 20} | |
| 273 | + | img := image.NewRGBA(image.Rect(0, 0, 40, 20)) | |
| 274 | + | n.Image = img | |
| 275 | + | out, err := Layout(n, Options{Width: 100, Height: 100}) | |
| 276 | + | if err != nil { | |
| 277 | + | t.Fatal(err) | |
| 278 | + | } | |
| 279 | + | if out.Tree.Border.W != 80 || out.Tree.Border.H != 40 { | |
| 280 | + | t.Fatalf("ratio: %+v", out.Tree.Border) | |
| 281 | + | } | |
| 282 | + | found := false | |
| 283 | + | for _, cmd := range out.Display { | |
| 284 | + | if cmd.Op == "image" { | |
| 285 | + | found = true | |
| 286 | + | if cmd.Image != img || cmd.Rect != out.Tree.Content { | |
| 287 | + | t.Fatalf("image command: %+v", cmd) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | if !found { | |
| 292 | + | t.Fatal("missing image command") | |
| 293 | + | } | |
| 294 | + | n.Style = &css.Style{Display: css.KeywordBlock, Height: px(10)} | |
| 295 | + | out, err = Layout(n, Options{Width: 100, Height: 100}) | |
| 296 | + | if err != nil { | |
| 297 | + | t.Fatal(err) | |
| 298 | + | } | |
| 299 | + | if out.Tree.Border.W != 20 || out.Tree.Border.H != 10 { | |
| 300 | + | t.Fatalf("ratio: %+v", out.Tree.Border) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestSizingSpaceAndBorderStyle(t *testing.T) { | |
| 305 | + | for _, tc := range []struct { | |
| 306 | + | box css.Keyword | |
| 307 | + | want float64 | |
| 308 | + | }{{css.KeywordContentBox, 80}, {css.KeywordBorderBox, 60}} { | |
| 309 | + | s := css.Style{Display: css.KeywordBlock, BoxSizing: tc.box, Width: px(50), MinWidth: px(60), Padding: css.LengthSides{Left: px(10), Right: px(10)}} | |
| 310 | + | out, err := Layout(node("b", Box, s), Options{Width: 100, Height: 100}) | |
| 311 | + | if err != nil { | |
| 312 | + | t.Fatal(err) | |
| 313 | + | } | |
| 314 | + | if out.Tree.Border.W != tc.want { | |
| 315 | + | t.Fatalf("box sizing %v: %v != %v", tc.box, out.Tree.Border.W, tc.want) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | s := css.Style{Display: css.KeywordBlock, Width: px(50), BorderWidth: css.LengthSides{Top: px(9), Right: px(9), Bottom: px(9), Left: px(9)}, BorderStyle: css.KeywordSides{Top: css.KeywordSolid}} | |
| 319 | + | out, err := Layout(node("b", Box, s), Options{Width: 100, Height: 100}) | |
| 320 | + | if err != nil { | |
| 321 | + | t.Fatal(err) | |
| 322 | + | } | |
| 323 | + | if out.Tree.Border.W != 50 || out.Tree.Content.Y != 9 || out.Tree.BorderWidths != (Edges{Top: 9}) { | |
| 324 | + | t.Fatalf("none border reserved: %+v", out.Tree) | |
| 325 | + | } | |
| 326 | + | for _, cmd := range out.Display { | |
| 327 | + | if cmd.Op == "border" && cmd.Widths != (Edges{Top: 9}) { | |
| 328 | + | t.Fatalf("none painted: %+v", cmd) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestFlexFreezeAndInsets(t *testing.T) { | |
| 334 | + | parent := css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordRow} | |
| 335 | + | first := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), FlexGrow: 1, FlexShrink: 1, MaxWidth: px(60)} | |
| 336 | + | second := first | |
| 337 | + | second.MaxWidth = css.Length{} | |
| 338 | + | root := node("r", Row, parent, node("a", Box, first), node("b", Box, second)) | |
| 339 | + | out, err := Layout(root, Options{Width: 200, Height: 30}) | |
| 340 | + | if err != nil { | |
| 341 | + | t.Fatal(err) | |
| 342 | + | } | |
| 343 | + | a, b := out.Tree.Children[0].Border, out.Tree.Children[1].Border | |
| 344 | + | if a.W != 60 || b.W != 140 || b.X != 60 { | |
| 345 | + | t.Fatalf("grow freeze: %+v %+v", a, b) | |
| 346 | + | } | |
| 347 | + | first.MaxWidth = css.Length{} | |
| 348 | + | first.MinWidth = px(40) | |
| 349 | + | second.MinWidth = css.Length{} | |
| 350 | + | root.Children[0].Style = &first | |
| 351 | + | root.Children[1].Style = &second | |
| 352 | + | out, err = Layout(root, Options{Width: 60, Height: 30}) | |
| 353 | + | if err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | a, b = out.Tree.Children[0].Border, out.Tree.Children[1].Border | |
| 357 | + | if a.W != 40 || b.W != 20 || b.X != 40 { | |
| 358 | + | t.Fatalf("shrink freeze: %+v %+v", a, b) | |
| 359 | + | } | |
| 360 | + | first = css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), Padding: css.LengthSides{Left: px(10), Right: px(10)}} | |
| 361 | + | root.Children = root.Children[:1] | |
| 362 | + | root.Children[0].Style = &first | |
| 363 | + | out, err = Layout(root, Options{Width: 70, Height: 30}) | |
| 364 | + | if err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | if out.Tree.Children[0].Border.W != 70 || out.Tree.Children[0].Content.W != 50 { | |
| 368 | + | t.Fatalf("flex insets: %+v", out.Tree.Children[0]) | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func TestOverflowAxes(t *testing.T) { | |
| 373 | + | s := css.Style{Display: css.KeywordBlock, Width: px(30), Height: px(30), OverflowX: css.KeywordVisible, OverflowY: css.KeywordHidden} | |
| 374 | + | child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(50)} | |
| 375 | + | n := node("parent", Box, s, node("child", Box, child)) | |
| 376 | + | n.ScrollX = 10 | |
| 377 | + | n.ScrollY = 10 | |
| 378 | + | out, err := Layout(n, Options{Width: 100, Height: 100}) | |
| 379 | + | if err != nil { | |
| 380 | + | t.Fatal(err) | |
| 381 | + | } | |
| 382 | + | if out.Tree.ScrollX != 0 || out.Tree.ScrollY != 0 || out.Tree.Children[0].Border.X != 0 || out.Tree.Clip.W != 2*limit || out.Tree.Clip.H != 30 { | |
| 383 | + | t.Fatalf("axes: %+v", out.Tree) | |
| 384 | + | } | |
| 385 | + | if Hit(out.Tree, 40, 10) != "child" || Hit(out.Tree, 10, 40) == "child" { | |
| 386 | + | t.Fatalf("clip hit: %s %s", Hit(out.Tree, 40, 10), Hit(out.Tree, 10, 40)) | |
| 387 | + | } | |
| 388 | + | if out.Display[0].Op != "clip-push" || out.Display[0].Rect.W != 2*limit { | |
| 389 | + | t.Fatalf("clip command: %+v", out.Display[0]) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestTextCSSMetrics(t *testing.T) { | |
| 394 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 395 | + | if err != nil { | |
| 396 | + | t.Fatal(err) | |
| 397 | + | } | |
| 398 | + | engine := text.NewEngine(catalog) | |
| 399 | + | st := css.Compile(css.UA(), css.Parse("text {width:60px;font-size:16px}")).Compute(&css.Element{Type: "text"}, nil).Style | |
| 400 | + | n := node("text", Text, *st) | |
| 401 | + | n.Content = "alpha beta gamma delta" | |
| 402 | + | base, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine}) | |
| 403 | + | if err != nil { | |
| 404 | + | t.Fatal(err) | |
| 405 | + | } | |
| 406 | + | if len(base.Tree.Lines) < 2 { | |
| 407 | + | t.Fatalf("expected wrapping: %+v", base.Tree) | |
| 408 | + | } | |
| 409 | + | baseLine1, baseH := base.Tree.Lines[1].Baseline, base.Tree.Border.H // values, not shared slices | |
| 410 | + | st.LineHeightKind = css.KeywordNumber | |
| 411 | + | st.LineHeightNumber = 2 | |
| 412 | + | n.Style = st | |
| 413 | + | spaced, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine}) | |
| 414 | + | if err != nil { | |
| 415 | + | t.Fatal(err) | |
| 416 | + | } | |
| 417 | + | if len(spaced.Tree.Lines) < 2 || spaced.Tree.Lines[0].Height != 32 || spaced.Tree.Border.H != float64(len(spaced.Tree.Lines))*32 || spaced.Tree.Lines[1].Baseline-spaced.Tree.Lines[0].Baseline != 32 { | |
| 418 | + | t.Fatalf("line height: %+v", spaced.Tree) | |
| 419 | + | } | |
| 420 | + | // The engine caches measurements: the line-height adjustment must not leak | |
| 421 | + | // into later layouts of the same text. | |
| 422 | + | st.LineHeightKind = css.KeywordNormal | |
| 423 | + | n.Style = st | |
| 424 | + | again, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine}) | |
| 425 | + | if err != nil { | |
| 426 | + | t.Fatal(err) | |
| 427 | + | } | |
| 428 | + | if again.Tree.Lines[1].Baseline != baseLine1 || again.Tree.Border.H != baseH { | |
| 429 | + | t.Fatalf("cached measurement mutated: %v vs %v", again.Tree.Lines[1].Baseline, baseLine1) | |
| 430 | + | } | |
| 431 | + | st.LineHeightKind = css.KeywordNumber | |
| 432 | + | n.Style = st | |
| 433 | + | st.TextAlign = css.KeywordCenter | |
| 434 | + | aligned, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine}) | |
| 435 | + | if err != nil { | |
| 436 | + | t.Fatal(err) | |
| 437 | + | } | |
| 438 | + | var original, center Command | |
| 439 | + | for _, cmd := range spaced.Display { | |
| 440 | + | if cmd.Op == "text" { | |
| 441 | + | original = cmd | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | for _, cmd := range aligned.Display { | |
| 445 | + | if cmd.Op == "text" { | |
| 446 | + | center = cmd | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | want := safe(aligned.Tree.Content.W-aligned.Tree.Lines[0].Width) / 2 | |
| 450 | + | if want <= 0 || math.Abs(center.Runs[0].X-original.Runs[0].X-want) > 1e-6 || math.Abs(center.Runs[0].Glyphs[0].X-original.Runs[0].Glyphs[0].X-want) > 1e-6 { | |
| 451 | + | t.Fatalf("align offset %v: %v %v", want, original.Runs[0].X, center.Runs[0].X) | |
| 452 | + | } | |
| 453 | + | st.LetterSpacing = px(4) | |
| 454 | + | st.LetterSpacingNormal = false | |
| 455 | + | st.TextAlign = css.KeywordStart | |
| 456 | + | n.Style = st | |
| 457 | + | letter, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine}) | |
| 458 | + | if err != nil { | |
| 459 | + | t.Fatal(err) | |
| 460 | + | } | |
| 461 | + | if letter.Tree.Lines[0].Width <= base.Tree.Lines[0].Width || letter.Tree.ContentSize.H < base.Tree.ContentSize.H { | |
| 462 | + | t.Fatalf("letter spacing: %v -> %v", base.Tree.Lines[0].Width, letter.Tree.Lines[0].Width) | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func TestFlexZeroFreeSpaceViolations(t *testing.T) { | |
| 467 | + | a := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), MinWidth: px(80), FlexShrink: 1} | |
| 468 | + | b := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), MaxWidth: px(20), FlexShrink: 1} | |
| 469 | + | root := node("row", Row, css.Style{Display: css.KeywordFlex}, node("min", Box, a), node("max", Box, b)) | |
| 470 | + | out, err := Layout(root, Options{Width: 100, Height: 20}) | |
| 471 | + | if err != nil { | |
| 472 | + | t.Fatal(err) | |
| 473 | + | } | |
| 474 | + | first, second := out.Tree.Children[0].Border, out.Tree.Children[1].Border | |
| 475 | + | if first.W != 80 || second.W != 20 || second.X != 80 { | |
| 476 | + | t.Fatalf("zero-free-space violations: %+v %+v", first, second) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | func TestFlexContentBoxMinLessThanInset(t *testing.T) { | |
| 480 | + | child := css.Style{Display: css.KeywordBlock, Width: px(5), MinWidth: px(10), Padding: css.LengthSides{Left: px(10), Right: px(10)}} | |
| 481 | + | root := node("row", Row, css.Style{Display: css.KeywordFlex}, node("child", Box, child)) | |
| 482 | + | out, err := Layout(root, Options{Width: 30, Height: 20}) | |
| 483 | + | if err != nil { | |
| 484 | + | t.Fatal(err) | |
| 485 | + | } | |
| 486 | + | if got := out.Tree.Children[0].Border.W; got != 30 { | |
| 487 | + | t.Fatalf("content-box min 10 + insets 20: got %v want 30", got) | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func TestDirectionalTextAlignment(t *testing.T) { | |
| 492 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 493 | + | if err != nil { | |
| 494 | + | t.Fatal(err) | |
| 495 | + | } | |
| 496 | + | engine := text.NewEngine(catalog) | |
| 497 | + | for _, content := range []string{"سلام", "שלום", "abc"} { | |
| 498 | + | st := css.Compile(css.UA(), css.Parse("text {width:120px;font-size:16px}")).Compute(&css.Element{Type: "text"}, nil).Style | |
| 499 | + | n := node("text", Text, *st) | |
| 500 | + | n.Content = content | |
| 501 | + | st.TextAlign = css.KeywordLeft | |
| 502 | + | n.Style = st | |
| 503 | + | left, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine}) | |
| 504 | + | if err != nil { | |
| 505 | + | t.Fatal(err) | |
| 506 | + | } | |
| 507 | + | st.TextAlign = css.KeywordStart | |
| 508 | + | start, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine}) | |
| 509 | + | if err != nil { | |
| 510 | + | t.Fatal(err) | |
| 511 | + | } | |
| 512 | + | st.TextAlign = css.KeywordEnd | |
| 513 | + | end, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine}) | |
| 514 | + | if err != nil { | |
| 515 | + | t.Fatal(err) | |
| 516 | + | } | |
| 517 | + | var leftX, startX, endX float64 | |
| 518 | + | for _, cmd := range left.Display { | |
| 519 | + | if cmd.Op == "text" { | |
| 520 | + | leftX = cmd.Runs[0].X | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | for _, cmd := range start.Display { | |
| 524 | + | if cmd.Op == "text" { | |
| 525 | + | startX = cmd.Runs[0].X | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | for _, cmd := range end.Display { | |
| 529 | + | if cmd.Op == "text" { | |
| 530 | + | endX = cmd.Runs[0].X | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | gap := safe(start.Tree.Content.W - start.Tree.Lines[0].Width) | |
| 534 | + | if gap <= 0 { | |
| 535 | + | t.Fatalf("%q: no free space", content) | |
| 536 | + | } | |
| 537 | + | if content == "abc" { | |
| 538 | + | if startX != leftX || math.Abs(endX-leftX-gap) > 1e-6 { | |
| 539 | + | t.Fatalf("LTR %q: left=%v start=%v end=%v gap=%v", content, leftX, startX, endX, gap) | |
| 540 | + | } | |
| 541 | + | } else if math.Abs(startX-leftX-gap) > 1e-6 || endX != leftX { | |
| 542 | + | t.Fatalf("RTL %q: left=%v start=%v end=%v gap=%v", content, leftX, startX, endX, gap) | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | } | |
A internal/layout/testdata/flex.json +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | { | |
| 2 | + | "Tree": { | |
| 3 | + | "ID": "r", | |
| 4 | + | "Kind": "row", | |
| 5 | + | "Border": { | |
| 6 | + | "X": 0, | |
| 7 | + | "Y": 0, | |
| 8 | + | "W": 120, | |
| 9 | + | "H": 24 | |
| 10 | + | }, | |
| 11 | + | "Content": { | |
| 12 | + | "X": 0, | |
| 13 | + | "Y": 0, | |
| 14 | + | "W": 120, | |
| 15 | + | "H": 24 | |
| 16 | + | }, | |
| 17 | + | "Clip": { | |
| 18 | + | "X": 0, | |
| 19 | + | "Y": 0, | |
| 20 | + | "W": 0, | |
| 21 | + | "H": 0 | |
| 22 | + | }, | |
| 23 | + | "Margin": { | |
| 24 | + | "Top": 0, | |
| 25 | + | "Right": 0, | |
| 26 | + | "Bottom": 0, | |
| 27 | + | "Left": 0 | |
| 28 | + | }, | |
| 29 | + | "Padding": { | |
| 30 | + | "Top": 0, | |
| 31 | + | "Right": 0, | |
| 32 | + | "Bottom": 0, | |
| 33 | + | "Left": 0 | |
| 34 | + | }, | |
| 35 | + | "BorderWidths": { | |
| 36 | + | "Top": 0, | |
| 37 | + | "Right": 0, | |
| 38 | + | "Bottom": 0, | |
| 39 | + | "Left": 0 | |
| 40 | + | }, | |
| 41 | + | "ContentSize": { | |
| 42 | + | "W": 120, | |
| 43 | + | "H": 24 | |
| 44 | + | }, | |
| 45 | + | "ScrollX": 0, | |
| 46 | + | "ScrollY": 0, | |
| 47 | + | "Children": [ | |
| 48 | + | { | |
| 49 | + | "ID": "a", | |
| 50 | + | "Kind": "box", | |
| 51 | + | "Border": { | |
| 52 | + | "X": 0, | |
| 53 | + | "Y": 0, | |
| 54 | + | "W": 55, | |
| 55 | + | "H": 12 | |
| 56 | + | }, | |
| 57 | + | "Content": { | |
| 58 | + | "X": 0, | |
| 59 | + | "Y": 0, | |
| 60 | + | "W": 55, | |
| 61 | + | "H": 12 | |
| 62 | + | }, | |
| 63 | + | "Clip": { | |
| 64 | + | "X": 0, | |
| 65 | + | "Y": 0, | |
| 66 | + | "W": 0, | |
| 67 | + | "H": 0 | |
| 68 | + | }, | |
| 69 | + | "Margin": { | |
| 70 | + | "Top": 0, | |
| 71 | + | "Right": 0, | |
| 72 | + | "Bottom": 0, | |
| 73 | + | "Left": 0 | |
| 74 | + | }, | |
| 75 | + | "Padding": { | |
| 76 | + | "Top": 0, | |
| 77 | + | "Right": 0, | |
| 78 | + | "Bottom": 0, | |
| 79 | + | "Left": 0 | |
| 80 | + | }, | |
| 81 | + | "BorderWidths": { | |
| 82 | + | "Top": 0, | |
| 83 | + | "Right": 0, | |
| 84 | + | "Bottom": 0, | |
| 85 | + | "Left": 0 | |
| 86 | + | }, | |
| 87 | + | "ContentSize": { | |
| 88 | + | "W": 0, | |
| 89 | + | "H": 0 | |
| 90 | + | }, | |
| 91 | + | "ScrollX": 0, | |
| 92 | + | "ScrollY": 0, | |
| 93 | + | "Children": null, | |
| 94 | + | "Clipped": false | |
| 95 | + | }, | |
| 96 | + | { | |
| 97 | + | "ID": "b", | |
| 98 | + | "Kind": "box", | |
| 99 | + | "Border": { | |
| 100 | + | "X": 65, | |
| 101 | + | "Y": 0, | |
| 102 | + | "W": 55, | |
| 103 | + | "H": 12 | |
| 104 | + | }, | |
| 105 | + | "Content": { | |
| 106 | + | "X": 65, | |
| 107 | + | "Y": 0, | |
| 108 | + | "W": 55, | |
| 109 | + | "H": 12 | |
| 110 | + | }, | |
| 111 | + | "Clip": { | |
| 112 | + | "X": 0, | |
| 113 | + | "Y": 0, | |
| 114 | + | "W": 0, | |
| 115 | + | "H": 0 | |
| 116 | + | }, | |
| 117 | + | "Margin": { | |
| 118 | + | "Top": 0, | |
| 119 | + | "Right": 0, | |
| 120 | + | "Bottom": 0, | |
| 121 | + | "Left": 0 | |
| 122 | + | }, | |
| 123 | + | "Padding": { | |
| 124 | + | "Top": 0, | |
| 125 | + | "Right": 0, | |
| 126 | + | "Bottom": 0, | |
| 127 | + | "Left": 0 | |
| 128 | + | }, | |
| 129 | + | "BorderWidths": { | |
| 130 | + | "Top": 0, | |
| 131 | + | "Right": 0, | |
| 132 | + | "Bottom": 0, | |
| 133 | + | "Left": 0 | |
| 134 | + | }, | |
| 135 | + | "ContentSize": { | |
| 136 | + | "W": 0, | |
| 137 | + | "H": 0 | |
| 138 | + | }, | |
| 139 | + | "ScrollX": 0, | |
| 140 | + | "ScrollY": 0, | |
| 141 | + | "Children": null, | |
| 142 | + | "Clipped": false | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | "ID": "c", | |
| 146 | + | "Kind": "box", | |
| 147 | + | "Border": { | |
| 148 | + | "X": 0, | |
| 149 | + | "Y": 12, | |
| 150 | + | "W": 120, | |
| 151 | + | "H": 12 | |
| 152 | + | }, | |
| 153 | + | "Content": { | |
| 154 | + | "X": 0, | |
| 155 | + | "Y": 12, | |
| 156 | + | "W": 120, | |
| 157 | + | "H": 12 | |
| 158 | + | }, | |
| 159 | + | "Clip": { | |
| 160 | + | "X": 0, | |
| 161 | + | "Y": 0, | |
| 162 | + | "W": 0, | |
| 163 | + | "H": 0 | |
| 164 | + | }, | |
| 165 | + | "Margin": { | |
| 166 | + | "Top": 0, | |
| 167 | + | "Right": 0, | |
| 168 | + | "Bottom": 0, | |
| 169 | + | "Left": 0 | |
| 170 | + | }, | |
| 171 | + | "Padding": { | |
| 172 | + | "Top": 0, | |
| 173 | + | "Right": 0, | |
| 174 | + | "Bottom": 0, | |
| 175 | + | "Left": 0 | |
| 176 | + | }, | |
| 177 | + | "BorderWidths": { | |
| 178 | + | "Top": 0, | |
| 179 | + | "Right": 0, | |
| 180 | + | "Bottom": 0, | |
| 181 | + | "Left": 0 | |
| 182 | + | }, | |
| 183 | + | "ContentSize": { | |
| 184 | + | "W": 0, | |
| 185 | + | "H": 0 | |
| 186 | + | }, | |
| 187 | + | "ScrollX": 0, | |
| 188 | + | "ScrollY": 0, | |
| 189 | + | "Children": null, | |
| 190 | + | "Clipped": false | |
| 191 | + | } | |
| 192 | + | ], | |
| 193 | + | "Clipped": false | |
| 194 | + | }, | |
| 195 | + | "Display": null | |
| 196 | + | } | |
A internal/layout/testdata/nested.json +266 -0
| @@ -0,0 +1,266 @@ | |||
| 1 | + | { | |
| 2 | + | "Tree": { | |
| 3 | + | "ID": "root", | |
| 4 | + | "Kind": "box", | |
| 5 | + | "Border": { | |
| 6 | + | "X": 0, | |
| 7 | + | "Y": 0, | |
| 8 | + | "W": 200, | |
| 9 | + | "H": 38 | |
| 10 | + | }, | |
| 11 | + | "Content": { | |
| 12 | + | "X": 6, | |
| 13 | + | "Y": 6, | |
| 14 | + | "W": 188, | |
| 15 | + | "H": 26 | |
| 16 | + | }, | |
| 17 | + | "Clip": { | |
| 18 | + | "X": 0, | |
| 19 | + | "Y": 0, | |
| 20 | + | "W": 0, | |
| 21 | + | "H": 0 | |
| 22 | + | }, | |
| 23 | + | "Margin": { | |
| 24 | + | "Top": 0, | |
| 25 | + | "Right": 0, | |
| 26 | + | "Bottom": 0, | |
| 27 | + | "Left": 0 | |
| 28 | + | }, | |
| 29 | + | "Padding": { | |
| 30 | + | "Top": 4, | |
| 31 | + | "Right": 4, | |
| 32 | + | "Bottom": 4, | |
| 33 | + | "Left": 4 | |
| 34 | + | }, | |
| 35 | + | "BorderWidths": { | |
| 36 | + | "Top": 2, | |
| 37 | + | "Right": 2, | |
| 38 | + | "Bottom": 2, | |
| 39 | + | "Left": 2 | |
| 40 | + | }, | |
| 41 | + | "ContentSize": { | |
| 42 | + | "W": 100, | |
| 43 | + | "H": 26 | |
| 44 | + | }, | |
| 45 | + | "ScrollX": 0, | |
| 46 | + | "ScrollY": 0, | |
| 47 | + | "Children": [ | |
| 48 | + | { | |
| 49 | + | "ID": "pic", | |
| 50 | + | "Kind": "image", | |
| 51 | + | "Border": { | |
| 52 | + | "X": 9, | |
| 53 | + | "Y": 9, | |
| 54 | + | "W": 94, | |
| 55 | + | "H": 20 | |
| 56 | + | }, | |
| 57 | + | "Content": { | |
| 58 | + | "X": 9, | |
| 59 | + | "Y": 9, | |
| 60 | + | "W": 94, | |
| 61 | + | "H": 20 | |
| 62 | + | }, | |
| 63 | + | "Clip": { | |
| 64 | + | "X": 0, | |
| 65 | + | "Y": 0, | |
| 66 | + | "W": 0, | |
| 67 | + | "H": 0 | |
| 68 | + | }, | |
| 69 | + | "Margin": { | |
| 70 | + | "Top": 3, | |
| 71 | + | "Right": 3, | |
| 72 | + | "Bottom": 3, | |
| 73 | + | "Left": 3 | |
| 74 | + | }, | |
| 75 | + | "Padding": { | |
| 76 | + | "Top": 0, | |
| 77 | + | "Right": 0, | |
| 78 | + | "Bottom": 0, | |
| 79 | + | "Left": 0 | |
| 80 | + | }, | |
| 81 | + | "BorderWidths": { | |
| 82 | + | "Top": 0, | |
| 83 | + | "Right": 0, | |
| 84 | + | "Bottom": 0, | |
| 85 | + | "Left": 0 | |
| 86 | + | }, | |
| 87 | + | "ContentSize": { | |
| 88 | + | "W": 0, | |
| 89 | + | "H": 0 | |
| 90 | + | }, | |
| 91 | + | "ScrollX": 0, | |
| 92 | + | "ScrollY": 0, | |
| 93 | + | "Children": null, | |
| 94 | + | "Clipped": false | |
| 95 | + | } | |
| 96 | + | ], | |
| 97 | + | "Clipped": false | |
| 98 | + | }, | |
| 99 | + | "Display": [ | |
| 100 | + | { | |
| 101 | + | "Op": "rect", | |
| 102 | + | "ID": "root", | |
| 103 | + | "Rect": { | |
| 104 | + | "X": 0, | |
| 105 | + | "Y": 0, | |
| 106 | + | "W": 200, | |
| 107 | + | "H": 38 | |
| 108 | + | }, | |
| 109 | + | "Color": { | |
| 110 | + | "R": 0, | |
| 111 | + | "G": 0, | |
| 112 | + | "B": 1, | |
| 113 | + | "A": 1 | |
| 114 | + | }, | |
| 115 | + | "Opacity": 1, | |
| 116 | + | "Widths": { | |
| 117 | + | "Top": 0, | |
| 118 | + | "Right": 0, | |
| 119 | + | "Bottom": 0, | |
| 120 | + | "Left": 0 | |
| 121 | + | }, | |
| 122 | + | "Colors": { | |
| 123 | + | "Top": { | |
| 124 | + | "R": 0, | |
| 125 | + | "G": 0, | |
| 126 | + | "B": 0, | |
| 127 | + | "A": 0 | |
| 128 | + | }, | |
| 129 | + | "Right": { | |
| 130 | + | "R": 0, | |
| 131 | + | "G": 0, | |
| 132 | + | "B": 0, | |
| 133 | + | "A": 0 | |
| 134 | + | }, | |
| 135 | + | "Bottom": { | |
| 136 | + | "R": 0, | |
| 137 | + | "G": 0, | |
| 138 | + | "B": 0, | |
| 139 | + | "A": 0 | |
| 140 | + | }, | |
| 141 | + | "Left": { | |
| 142 | + | "R": 0, | |
| 143 | + | "G": 0, | |
| 144 | + | "B": 0, | |
| 145 | + | "A": 0 | |
| 146 | + | } | |
| 147 | + | }, | |
| 148 | + | "Radii": [ | |
| 149 | + | 0, | |
| 150 | + | 0, | |
| 151 | + | 0, | |
| 152 | + | 0 | |
| 153 | + | ] | |
| 154 | + | }, | |
| 155 | + | { | |
| 156 | + | "Op": "border", | |
| 157 | + | "ID": "root", | |
| 158 | + | "Rect": { | |
| 159 | + | "X": 0, | |
| 160 | + | "Y": 0, | |
| 161 | + | "W": 200, | |
| 162 | + | "H": 38 | |
| 163 | + | }, | |
| 164 | + | "Color": { | |
| 165 | + | "R": 1, | |
| 166 | + | "G": 0, | |
| 167 | + | "B": 0, | |
| 168 | + | "A": 1 | |
| 169 | + | }, | |
| 170 | + | "Opacity": 1, | |
| 171 | + | "Widths": { | |
| 172 | + | "Top": 2, | |
| 173 | + | "Right": 2, | |
| 174 | + | "Bottom": 2, | |
| 175 | + | "Left": 2 | |
| 176 | + | }, | |
| 177 | + | "Colors": { | |
| 178 | + | "Top": { | |
| 179 | + | "R": 1, | |
| 180 | + | "G": 0, | |
| 181 | + | "B": 0, | |
| 182 | + | "A": 1 | |
| 183 | + | }, | |
| 184 | + | "Right": { | |
| 185 | + | "R": 1, | |
| 186 | + | "G": 0, | |
| 187 | + | "B": 0, | |
| 188 | + | "A": 1 | |
| 189 | + | }, | |
| 190 | + | "Bottom": { | |
| 191 | + | "R": 1, | |
| 192 | + | "G": 0, | |
| 193 | + | "B": 0, | |
| 194 | + | "A": 1 | |
| 195 | + | }, | |
| 196 | + | "Left": { | |
| 197 | + | "R": 1, | |
| 198 | + | "G": 0, | |
| 199 | + | "B": 0, | |
| 200 | + | "A": 1 | |
| 201 | + | } | |
| 202 | + | }, | |
| 203 | + | "Radii": [ | |
| 204 | + | 0, | |
| 205 | + | 0, | |
| 206 | + | 0, | |
| 207 | + | 0 | |
| 208 | + | ] | |
| 209 | + | }, | |
| 210 | + | { | |
| 211 | + | "Op": "image", | |
| 212 | + | "ID": "pic", | |
| 213 | + | "Rect": { | |
| 214 | + | "X": 9, | |
| 215 | + | "Y": 9, | |
| 216 | + | "W": 94, | |
| 217 | + | "H": 20 | |
| 218 | + | }, | |
| 219 | + | "Color": { | |
| 220 | + | "R": 0, | |
| 221 | + | "G": 0, | |
| 222 | + | "B": 0, | |
| 223 | + | "A": 0 | |
| 224 | + | }, | |
| 225 | + | "Opacity": 1, | |
| 226 | + | "Widths": { | |
| 227 | + | "Top": 0, | |
| 228 | + | "Right": 0, | |
| 229 | + | "Bottom": 0, | |
| 230 | + | "Left": 0 | |
| 231 | + | }, | |
| 232 | + | "Colors": { | |
| 233 | + | "Top": { | |
| 234 | + | "R": 0, | |
| 235 | + | "G": 0, | |
| 236 | + | "B": 0, | |
| 237 | + | "A": 0 | |
| 238 | + | }, | |
| 239 | + | "Right": { | |
| 240 | + | "R": 0, | |
| 241 | + | "G": 0, | |
| 242 | + | "B": 0, | |
| 243 | + | "A": 0 | |
| 244 | + | }, | |
| 245 | + | "Bottom": { | |
| 246 | + | "R": 0, | |
| 247 | + | "G": 0, | |
| 248 | + | "B": 0, | |
| 249 | + | "A": 0 | |
| 250 | + | }, | |
| 251 | + | "Left": { | |
| 252 | + | "R": 0, | |
| 253 | + | "G": 0, | |
| 254 | + | "B": 0, | |
| 255 | + | "A": 0 | |
| 256 | + | } | |
| 257 | + | }, | |
| 258 | + | "Radii": [ | |
| 259 | + | 0, | |
| 260 | + | 0, | |
| 261 | + | 0, | |
| 262 | + | 0 | |
| 263 | + | ] | |
| 264 | + | } | |
| 265 | + | ] | |
| 266 | + | } | |
A internal/layout/testdata/scroll.json +418 -0
| @@ -0,0 +1,418 @@ | |||
| 1 | + | { | |
| 2 | + | "Tree": { | |
| 3 | + | "ID": "scroll", | |
| 4 | + | "Kind": "scroll", | |
| 5 | + | "Border": { | |
| 6 | + | "X": 0, | |
| 7 | + | "Y": 0, | |
| 8 | + | "W": 30, | |
| 9 | + | "H": 30 | |
| 10 | + | }, | |
| 11 | + | "Content": { | |
| 12 | + | "X": 0, | |
| 13 | + | "Y": 0, | |
| 14 | + | "W": 30, | |
| 15 | + | "H": 30 | |
| 16 | + | }, | |
| 17 | + | "Clip": { | |
| 18 | + | "X": 0, | |
| 19 | + | "Y": 0, | |
| 20 | + | "W": 30, | |
| 21 | + | "H": 30 | |
| 22 | + | }, | |
| 23 | + | "Margin": { | |
| 24 | + | "Top": 0, | |
| 25 | + | "Right": 0, | |
| 26 | + | "Bottom": 0, | |
| 27 | + | "Left": 0 | |
| 28 | + | }, | |
| 29 | + | "Padding": { | |
| 30 | + | "Top": 0, | |
| 31 | + | "Right": 0, | |
| 32 | + | "Bottom": 0, | |
| 33 | + | "Left": 0 | |
| 34 | + | }, | |
| 35 | + | "BorderWidths": { | |
| 36 | + | "Top": 0, | |
| 37 | + | "Right": 0, | |
| 38 | + | "Bottom": 0, | |
| 39 | + | "Left": 0 | |
| 40 | + | }, | |
| 41 | + | "ContentSize": { | |
| 42 | + | "W": 30, | |
| 43 | + | "H": 50 | |
| 44 | + | }, | |
| 45 | + | "ScrollX": 0, | |
| 46 | + | "ScrollY": 10, | |
| 47 | + | "Children": [ | |
| 48 | + | { | |
| 49 | + | "ID": "stack", | |
| 50 | + | "Kind": "stack", | |
| 51 | + | "Border": { | |
| 52 | + | "X": 0, | |
| 53 | + | "Y": -10, | |
| 54 | + | "W": 30, | |
| 55 | + | "H": 50 | |
| 56 | + | }, | |
| 57 | + | "Content": { | |
| 58 | + | "X": 0, | |
| 59 | + | "Y": -10, | |
| 60 | + | "W": 30, | |
| 61 | + | "H": 50 | |
| 62 | + | }, | |
| 63 | + | "Clip": { | |
| 64 | + | "X": 0, | |
| 65 | + | "Y": -10, | |
| 66 | + | "W": 0, | |
| 67 | + | "H": 0 | |
| 68 | + | }, | |
| 69 | + | "Margin": { | |
| 70 | + | "Top": 0, | |
| 71 | + | "Right": 0, | |
| 72 | + | "Bottom": 0, | |
| 73 | + | "Left": 0 | |
| 74 | + | }, | |
| 75 | + | "Padding": { | |
| 76 | + | "Top": 0, | |
| 77 | + | "Right": 0, | |
| 78 | + | "Bottom": 0, | |
| 79 | + | "Left": 0 | |
| 80 | + | }, | |
| 81 | + | "BorderWidths": { | |
| 82 | + | "Top": 0, | |
| 83 | + | "Right": 0, | |
| 84 | + | "Bottom": 0, | |
| 85 | + | "Left": 0 | |
| 86 | + | }, | |
| 87 | + | "ContentSize": { | |
| 88 | + | "W": 50, | |
| 89 | + | "H": 50 | |
| 90 | + | }, | |
| 91 | + | "ScrollX": 0, | |
| 92 | + | "ScrollY": 0, | |
| 93 | + | "Children": [ | |
| 94 | + | { | |
| 95 | + | "ID": "back", | |
| 96 | + | "Kind": "box", | |
| 97 | + | "Border": { | |
| 98 | + | "X": 0, | |
| 99 | + | "Y": -10, | |
| 100 | + | "W": 50, | |
| 101 | + | "H": 50 | |
| 102 | + | }, | |
| 103 | + | "Content": { | |
| 104 | + | "X": 0, | |
| 105 | + | "Y": -10, | |
| 106 | + | "W": 50, | |
| 107 | + | "H": 50 | |
| 108 | + | }, | |
| 109 | + | "Clip": { | |
| 110 | + | "X": 0, | |
| 111 | + | "Y": -10, | |
| 112 | + | "W": 0, | |
| 113 | + | "H": 0 | |
| 114 | + | }, | |
| 115 | + | "Margin": { | |
| 116 | + | "Top": 0, | |
| 117 | + | "Right": 0, | |
| 118 | + | "Bottom": 0, | |
| 119 | + | "Left": 0 | |
| 120 | + | }, | |
| 121 | + | "Padding": { | |
| 122 | + | "Top": 0, | |
| 123 | + | "Right": 0, | |
| 124 | + | "Bottom": 0, | |
| 125 | + | "Left": 0 | |
| 126 | + | }, | |
| 127 | + | "BorderWidths": { | |
| 128 | + | "Top": 0, | |
| 129 | + | "Right": 0, | |
| 130 | + | "Bottom": 0, | |
| 131 | + | "Left": 0 | |
| 132 | + | }, | |
| 133 | + | "ContentSize": { | |
| 134 | + | "W": 0, | |
| 135 | + | "H": 0 | |
| 136 | + | }, | |
| 137 | + | "ScrollX": 0, | |
| 138 | + | "ScrollY": 0, | |
| 139 | + | "Children": null, | |
| 140 | + | "Clipped": false | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | "ID": "front", | |
| 144 | + | "Kind": "box", | |
| 145 | + | "Border": { | |
| 146 | + | "X": 0, | |
| 147 | + | "Y": -10, | |
| 148 | + | "W": 50, | |
| 149 | + | "H": 50 | |
| 150 | + | }, | |
| 151 | + | "Content": { | |
| 152 | + | "X": 0, | |
| 153 | + | "Y": -10, | |
| 154 | + | "W": 50, | |
| 155 | + | "H": 50 | |
| 156 | + | }, | |
| 157 | + | "Clip": { | |
| 158 | + | "X": 0, | |
| 159 | + | "Y": -10, | |
| 160 | + | "W": 0, | |
| 161 | + | "H": 0 | |
| 162 | + | }, | |
| 163 | + | "Margin": { | |
| 164 | + | "Top": 0, | |
| 165 | + | "Right": 0, | |
| 166 | + | "Bottom": 0, | |
| 167 | + | "Left": 0 | |
| 168 | + | }, | |
| 169 | + | "Padding": { | |
| 170 | + | "Top": 0, | |
| 171 | + | "Right": 0, | |
| 172 | + | "Bottom": 0, | |
| 173 | + | "Left": 0 | |
| 174 | + | }, | |
| 175 | + | "BorderWidths": { | |
| 176 | + | "Top": 0, | |
| 177 | + | "Right": 0, | |
| 178 | + | "Bottom": 0, | |
| 179 | + | "Left": 0 | |
| 180 | + | }, | |
| 181 | + | "ContentSize": { | |
| 182 | + | "W": 0, | |
| 183 | + | "H": 0 | |
| 184 | + | }, | |
| 185 | + | "ScrollX": 0, | |
| 186 | + | "ScrollY": 0, | |
| 187 | + | "Children": null, | |
| 188 | + | "Clipped": false | |
| 189 | + | } | |
| 190 | + | ], | |
| 191 | + | "Clipped": false | |
| 192 | + | } | |
| 193 | + | ], | |
| 194 | + | "Clipped": true | |
| 195 | + | }, | |
| 196 | + | "Display": [ | |
| 197 | + | { | |
| 198 | + | "Op": "clip-push", | |
| 199 | + | "ID": "scroll", | |
| 200 | + | "Rect": { | |
| 201 | + | "X": 0, | |
| 202 | + | "Y": 0, | |
| 203 | + | "W": 30, | |
| 204 | + | "H": 30 | |
| 205 | + | }, | |
| 206 | + | "Color": { | |
| 207 | + | "R": 0, | |
| 208 | + | "G": 0, | |
| 209 | + | "B": 0, | |
| 210 | + | "A": 0 | |
| 211 | + | }, | |
| 212 | + | "Opacity": 0, | |
| 213 | + | "Widths": { | |
| 214 | + | "Top": 0, | |
| 215 | + | "Right": 0, | |
| 216 | + | "Bottom": 0, | |
| 217 | + | "Left": 0 | |
| 218 | + | }, | |
| 219 | + | "Colors": { | |
| 220 | + | "Top": { | |
| 221 | + | "R": 0, | |
| 222 | + | "G": 0, | |
| 223 | + | "B": 0, | |
| 224 | + | "A": 0 | |
| 225 | + | }, | |
| 226 | + | "Right": { | |
| 227 | + | "R": 0, | |
| 228 | + | "G": 0, | |
| 229 | + | "B": 0, | |
| 230 | + | "A": 0 | |
| 231 | + | }, | |
| 232 | + | "Bottom": { | |
| 233 | + | "R": 0, | |
| 234 | + | "G": 0, | |
| 235 | + | "B": 0, | |
| 236 | + | "A": 0 | |
| 237 | + | }, | |
| 238 | + | "Left": { | |
| 239 | + | "R": 0, | |
| 240 | + | "G": 0, | |
| 241 | + | "B": 0, | |
| 242 | + | "A": 0 | |
| 243 | + | } | |
| 244 | + | }, | |
| 245 | + | "Radii": [ | |
| 246 | + | 0, | |
| 247 | + | 0, | |
| 248 | + | 0, | |
| 249 | + | 0 | |
| 250 | + | ] | |
| 251 | + | }, | |
| 252 | + | { | |
| 253 | + | "Op": "rect", | |
| 254 | + | "ID": "back", | |
| 255 | + | "Rect": { | |
| 256 | + | "X": 0, | |
| 257 | + | "Y": -10, | |
| 258 | + | "W": 50, | |
| 259 | + | "H": 50 | |
| 260 | + | }, | |
| 261 | + | "Color": { | |
| 262 | + | "R": 1, | |
| 263 | + | "G": 0, | |
| 264 | + | "B": 0, | |
| 265 | + | "A": 1 | |
| 266 | + | }, | |
| 267 | + | "Opacity": 0, | |
| 268 | + | "Widths": { | |
| 269 | + | "Top": 0, | |
| 270 | + | "Right": 0, | |
| 271 | + | "Bottom": 0, | |
| 272 | + | "Left": 0 | |
| 273 | + | }, | |
| 274 | + | "Colors": { | |
| 275 | + | "Top": { | |
| 276 | + | "R": 0, | |
| 277 | + | "G": 0, | |
| 278 | + | "B": 0, | |
| 279 | + | "A": 0 | |
| 280 | + | }, | |
| 281 | + | "Right": { | |
| 282 | + | "R": 0, | |
| 283 | + | "G": 0, | |
| 284 | + | "B": 0, | |
| 285 | + | "A": 0 | |
| 286 | + | }, | |
| 287 | + | "Bottom": { | |
| 288 | + | "R": 0, | |
| 289 | + | "G": 0, | |
| 290 | + | "B": 0, | |
| 291 | + | "A": 0 | |
| 292 | + | }, | |
| 293 | + | "Left": { | |
| 294 | + | "R": 0, | |
| 295 | + | "G": 0, | |
| 296 | + | "B": 0, | |
| 297 | + | "A": 0 | |
| 298 | + | } | |
| 299 | + | }, | |
| 300 | + | "Radii": [ | |
| 301 | + | 0, | |
| 302 | + | 0, | |
| 303 | + | 0, | |
| 304 | + | 0 | |
| 305 | + | ] | |
| 306 | + | }, | |
| 307 | + | { | |
| 308 | + | "Op": "rect", | |
| 309 | + | "ID": "front", | |
| 310 | + | "Rect": { | |
| 311 | + | "X": 0, | |
| 312 | + | "Y": -10, | |
| 313 | + | "W": 50, | |
| 314 | + | "H": 50 | |
| 315 | + | }, | |
| 316 | + | "Color": { | |
| 317 | + | "R": 1, | |
| 318 | + | "G": 0, | |
| 319 | + | "B": 0, | |
| 320 | + | "A": 1 | |
| 321 | + | }, | |
| 322 | + | "Opacity": 0, | |
| 323 | + | "Widths": { | |
| 324 | + | "Top": 0, | |
| 325 | + | "Right": 0, | |
| 326 | + | "Bottom": 0, | |
| 327 | + | "Left": 0 | |
| 328 | + | }, | |
| 329 | + | "Colors": { | |
| 330 | + | "Top": { | |
| 331 | + | "R": 0, | |
| 332 | + | "G": 0, | |
| 333 | + | "B": 0, | |
| 334 | + | "A": 0 | |
| 335 | + | }, | |
| 336 | + | "Right": { | |
| 337 | + | "R": 0, | |
| 338 | + | "G": 0, | |
| 339 | + | "B": 0, | |
| 340 | + | "A": 0 | |
| 341 | + | }, | |
| 342 | + | "Bottom": { | |
| 343 | + | "R": 0, | |
| 344 | + | "G": 0, | |
| 345 | + | "B": 0, | |
| 346 | + | "A": 0 | |
| 347 | + | }, | |
| 348 | + | "Left": { | |
| 349 | + | "R": 0, | |
| 350 | + | "G": 0, | |
| 351 | + | "B": 0, | |
| 352 | + | "A": 0 | |
| 353 | + | } | |
| 354 | + | }, | |
| 355 | + | "Radii": [ | |
| 356 | + | 0, | |
| 357 | + | 0, | |
| 358 | + | 0, | |
| 359 | + | 0 | |
| 360 | + | ] | |
| 361 | + | }, | |
| 362 | + | { | |
| 363 | + | "Op": "clip-pop", | |
| 364 | + | "ID": "scroll", | |
| 365 | + | "Rect": { | |
| 366 | + | "X": 0, | |
| 367 | + | "Y": 0, | |
| 368 | + | "W": 30, | |
| 369 | + | "H": 30 | |
| 370 | + | }, | |
| 371 | + | "Color": { | |
| 372 | + | "R": 0, | |
| 373 | + | "G": 0, | |
| 374 | + | "B": 0, | |
| 375 | + | "A": 0 | |
| 376 | + | }, | |
| 377 | + | "Opacity": 0, | |
| 378 | + | "Widths": { | |
| 379 | + | "Top": 0, | |
| 380 | + | "Right": 0, | |
| 381 | + | "Bottom": 0, | |
| 382 | + | "Left": 0 | |
| 383 | + | }, | |
| 384 | + | "Colors": { | |
| 385 | + | "Top": { | |
| 386 | + | "R": 0, | |
| 387 | + | "G": 0, | |
| 388 | + | "B": 0, | |
| 389 | + | "A": 0 | |
| 390 | + | }, | |
| 391 | + | "Right": { | |
| 392 | + | "R": 0, | |
| 393 | + | "G": 0, | |
| 394 | + | "B": 0, | |
| 395 | + | "A": 0 | |
| 396 | + | }, | |
| 397 | + | "Bottom": { | |
| 398 | + | "R": 0, | |
| 399 | + | "G": 0, | |
| 400 | + | "B": 0, | |
| 401 | + | "A": 0 | |
| 402 | + | }, | |
| 403 | + | "Left": { | |
| 404 | + | "R": 0, | |
| 405 | + | "G": 0, | |
| 406 | + | "B": 0, | |
| 407 | + | "A": 0 | |
| 408 | + | } | |
| 409 | + | }, | |
| 410 | + | "Radii": [ | |
| 411 | + | 0, | |
| 412 | + | 0, | |
| 413 | + | 0, | |
| 414 | + | 0 | |
| 415 | + | ] | |
| 416 | + | } | |
| 417 | + | ] | |
| 418 | + | } | |
A internal/platform/wayland/clipboard.go +391 -0
| @@ -0,0 +1,391 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "sync" | |
| 11 | + | "syscall" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefergui/internal/edit" | |
| 15 | + | "github.com/bnema/wlturbo" | |
| 16 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 17 | + | "github.com/bnema/wlturbo/wl" | |
| 18 | + | "golang.org/x/sys/unix" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | mimeUTF8 = "text/plain;charset=utf-8" | |
| 23 | + | mimeX11 = "UTF8_STRING" | |
| 24 | + | mimePlain = "text/plain" | |
| 25 | + | transferTimeout = 3 * time.Second | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // ClipboardResult is delivered through the session event queue, never from | |
| 29 | + | // the reader into the editor. Data may contain invalid UTF-8; edit validates it. | |
| 30 | + | type ClipboardResult struct { | |
| 31 | + | Data []byte | |
| 32 | + | Err error | |
| 33 | + | } | |
| 34 | + | type ClipboardEvent struct { | |
| 35 | + | kind string | |
| 36 | + | offer *core.DataOffer | |
| 37 | + | mime string | |
| 38 | + | source *core.DataSource | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Clipboard is the asynchronous Wayland clipboard adapter. The synchronous | |
| 42 | + | // edit.Clipboard.ReadText contract cannot be used on the UI loop: callers must | |
| 43 | + | // use ReadTextAsync and dispatch ClipboardResult on their next frame. | |
| 44 | + | type Clipboard struct { | |
| 45 | + | manager *core.DataDeviceManager | |
| 46 | + | version uint32 // negotiated wl_data_device_manager version | |
| 47 | + | device *core.DataDevice | |
| 48 | + | offer *core.DataOffer | |
| 49 | + | dnd *core.DataOffer | |
| 50 | + | pending map[*core.DataOffer]map[string]bool | |
| 51 | + | offers map[uint32]*core.DataOffer | |
| 52 | + | mimes map[string]bool | |
| 53 | + | source *core.DataSource | |
| 54 | + | serial func() uint32 | |
| 55 | + | post func(ClipboardEvent) | |
| 56 | + | closed bool | |
| 57 | + | stop context.CancelFunc | |
| 58 | + | workers sync.WaitGroup | |
| 59 | + | mu sync.Mutex | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | var _ edit.AsyncClipboard = (*Clipboard)(nil) | |
| 63 | + | ||
| 64 | + | // NewClipboard binds the optional manager at its announced version and creates | |
| 65 | + | // one data device for seat. Call from the session loop after seat binding. | |
| 66 | + | func NewClipboard(d *wl.Display, seat *core.Seat, serial func() uint32, post func(ClipboardEvent)) (*Clipboard, error) { | |
| 67 | + | if seat == nil || d == nil { | |
| 68 | + | return nil, nil | |
| 69 | + | } | |
| 70 | + | manager := core.NewDataDeviceManager(d.Context()) | |
| 71 | + | version, err := d.Registry().BindNegotiated(core.DataDeviceManagerInterface, 3, manager) | |
| 72 | + | if err != nil { | |
| 73 | + | d.Context().Unregister(manager) | |
| 74 | + | if errors.Is(err, wlturbo.ErrGlobalNotFound) { | |
| 75 | + | return nil, nil | |
| 76 | + | } | |
| 77 | + | return nil, err | |
| 78 | + | } | |
| 79 | + | device, err := manager.GetDataDevice(seat) | |
| 80 | + | if err != nil { | |
| 81 | + | d.Context().Unregister(manager) | |
| 82 | + | return nil, err | |
| 83 | + | } | |
| 84 | + | c := &Clipboard{manager: manager, version: version, device: device, serial: serial, post: post, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)} | |
| 85 | + | device.OnDataOffer(func(o *core.DataOffer) { | |
| 86 | + | c.mu.Lock() | |
| 87 | + | if c.closed { | |
| 88 | + | c.mu.Unlock() | |
| 89 | + | _ = o.Destroy() | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | c.offers[o.ID()] = o | |
| 93 | + | c.mu.Unlock() | |
| 94 | + | c.post(ClipboardEvent{kind: "offer", offer: o}) | |
| 95 | + | o.OnOffer(func(m string) { c.post(ClipboardEvent{kind: "mime", offer: o, mime: m}) }) | |
| 96 | + | }) | |
| 97 | + | device.OnSelection(func(id uint32) { c.post(ClipboardEvent{kind: "selection", offer: c.findOffer(id)}) }) | |
| 98 | + | // Drag-and-drop is unsupported in V1; retain its offer until leave/drop. | |
| 99 | + | device.OnEnter(func(_ uint32, _ uint32, _, _ wl.Fixed, id uint32) { | |
| 100 | + | c.post(ClipboardEvent{kind: "enter", offer: c.findOffer(id)}) | |
| 101 | + | }) | |
| 102 | + | device.OnLeave(func() { c.post(ClipboardEvent{kind: "leave"}) }) | |
| 103 | + | device.OnDrop(func() { c.post(ClipboardEvent{kind: "drop"}) }) | |
| 104 | + | return c, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // findOffer only resolves IDs from reader-owned notifications. It does not | |
| 108 | + | // mutate clipboard state; the loop decides which offer is current. | |
| 109 | + | func (c *Clipboard) findOffer(id uint32) *core.DataOffer { | |
| 110 | + | if id == 0 { | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | c.mu.Lock() | |
| 114 | + | defer c.mu.Unlock() | |
| 115 | + | return c.offers[id] | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ApplyClipboard handles reader events on the session loop. | |
| 119 | + | func (c *Clipboard) ApplyClipboard(ev ClipboardEvent) { | |
| 120 | + | switch ev.kind { | |
| 121 | + | case "offer": | |
| 122 | + | c.pending[ev.offer] = make(map[string]bool) | |
| 123 | + | case "mime": | |
| 124 | + | if m, ok := c.pending[ev.offer]; ok { | |
| 125 | + | m[ev.mime] = true | |
| 126 | + | } else if ev.offer == c.offer { | |
| 127 | + | c.mimes[ev.mime] = true | |
| 128 | + | } | |
| 129 | + | case "selection": | |
| 130 | + | if c.offer != nil && c.offer != ev.offer { | |
| 131 | + | c.destroyOffer(c.offer) | |
| 132 | + | } | |
| 133 | + | c.offer = ev.offer | |
| 134 | + | c.mimes = c.pending[ev.offer] | |
| 135 | + | if c.mimes == nil { | |
| 136 | + | c.mimes = make(map[string]bool) | |
| 137 | + | } | |
| 138 | + | delete(c.pending, ev.offer) | |
| 139 | + | for o := range c.pending { | |
| 140 | + | if o == c.dnd { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | if o != ev.offer { | |
| 144 | + | c.destroyOffer(o) | |
| 145 | + | delete(c.pending, o) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | case "enter": | |
| 149 | + | c.clearDnD() | |
| 150 | + | c.dnd = ev.offer | |
| 151 | + | delete(c.pending, ev.offer) | |
| 152 | + | case "leave", "drop": | |
| 153 | + | c.clearDnD() | |
| 154 | + | case "cancelled": | |
| 155 | + | if c.source == ev.source { | |
| 156 | + | _ = c.source.Destroy() | |
| 157 | + | c.source = nil | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | func (c *Clipboard) clearDnD() { | |
| 162 | + | if c.dnd != nil && c.dnd != c.offer { | |
| 163 | + | c.destroyOffer(c.dnd) | |
| 164 | + | } | |
| 165 | + | c.dnd = nil | |
| 166 | + | } | |
| 167 | + | func (c *Clipboard) destroyOffer(o *core.DataOffer) { | |
| 168 | + | c.mu.Lock() | |
| 169 | + | delete(c.offers, o.ID()) | |
| 170 | + | c.mu.Unlock() | |
| 171 | + | _ = o.Destroy() | |
| 172 | + | } | |
| 173 | + | func (c *Clipboard) bestMIME() string { | |
| 174 | + | for _, m := range []string{mimeUTF8, mimeX11, mimePlain} { | |
| 175 | + | if c.mimes[m] { | |
| 176 | + | return m | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | return "" | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // ReadTextAsync must be called on the loop. The compositor receives the write | |
| 183 | + | // end; a worker reads at most limit+1 bytes. The caller posts the callback to | |
| 184 | + | // its UI event queue. Concurrent reads cancel the previous transfer. Close | |
| 185 | + | // cancels and joins reads, not callbacks: a callback racing Close may still run | |
| 186 | + | // after Close returns. Callers must discard results when shutting down. | |
| 187 | + | func (c *Clipboard) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error { | |
| 188 | + | if c == nil || c.offer == nil { | |
| 189 | + | return errors.New("clipboard unavailable") | |
| 190 | + | } | |
| 191 | + | mime := c.bestMIME() | |
| 192 | + | if mime == "" { | |
| 193 | + | return errors.New("no text MIME offered") | |
| 194 | + | } | |
| 195 | + | if limit < 0 || limit > edit.MaxClipboardBytes { | |
| 196 | + | limit = edit.MaxClipboardBytes | |
| 197 | + | } | |
| 198 | + | fd := make([]int, 2) | |
| 199 | + | if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | r := os.NewFile(uintptr(fd[0]), "clipboard-read") | |
| 203 | + | if err := c.offer.Receive(mime, fd[1]); err != nil { | |
| 204 | + | _ = syscall.Close(fd[1]) | |
| 205 | + | _ = r.Close() | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | // WLTurbo closes the sent FD after a successful send; do not close it twice. | |
| 209 | + | ctx, cancel := context.WithTimeout(ctx, transferTimeout) | |
| 210 | + | c.mu.Lock() | |
| 211 | + | if c.closed { | |
| 212 | + | c.mu.Unlock() | |
| 213 | + | cancel() | |
| 214 | + | _ = r.Close() | |
| 215 | + | return errors.New("clipboard closed") | |
| 216 | + | } | |
| 217 | + | if c.stop != nil { | |
| 218 | + | c.stop() | |
| 219 | + | } | |
| 220 | + | c.stop = cancel | |
| 221 | + | c.workers.Add(1) | |
| 222 | + | c.mu.Unlock() | |
| 223 | + | go func() { | |
| 224 | + | defer c.workers.Done() | |
| 225 | + | defer cancel() | |
| 226 | + | defer r.Close() | |
| 227 | + | // readClipboard polls ctx every 50ms; closing r from another goroutine | |
| 228 | + | // while it is read would race on the descriptor. | |
| 229 | + | data, e := readClipboard(ctx, r, limit) | |
| 230 | + | if ctx.Err() != nil { | |
| 231 | + | e = ctx.Err() | |
| 232 | + | } | |
| 233 | + | c.mu.Lock() | |
| 234 | + | open := !c.closed | |
| 235 | + | c.mu.Unlock() | |
| 236 | + | if !open || ctx.Err() != nil { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | // Arbitrary callbacks cannot be forcibly interrupted. They are not | |
| 240 | + | // joined by Close: a blocked UI enqueue must not stall shutdown. | |
| 241 | + | go deliver(data, e) | |
| 242 | + | }() | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // readClipboard polls a nonblocking pipe so cancellation and timeouts are | |
| 247 | + | // effective even if the compositor never closes its write end. | |
| 248 | + | func readClipboard(ctx context.Context, r *os.File, limit int) ([]byte, error) { | |
| 249 | + | data := make([]byte, 0, min(limit, 4096)) | |
| 250 | + | buf := make([]byte, 4096) | |
| 251 | + | for { | |
| 252 | + | if err := ctx.Err(); err != nil { | |
| 253 | + | return nil, err | |
| 254 | + | } | |
| 255 | + | n, e := unix.Read(int(r.Fd()), buf[:min(len(buf), limit+1-len(data))]) | |
| 256 | + | if n > 0 { | |
| 257 | + | data = append(data, buf[:n]...) | |
| 258 | + | if len(data) > limit { | |
| 259 | + | return nil, fmt.Errorf("clipboard exceeds %d bytes", limit) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | if e == nil && n == 0 { | |
| 263 | + | return data, nil | |
| 264 | + | } | |
| 265 | + | if e == nil { | |
| 266 | + | continue | |
| 267 | + | } | |
| 268 | + | if e != unix.EAGAIN && e != unix.EINTR { | |
| 269 | + | return nil, e | |
| 270 | + | } | |
| 271 | + | poll := []unix.PollFd{{Fd: int32(r.Fd()), Events: unix.POLLIN | unix.POLLHUP}} | |
| 272 | + | _, e = unix.Poll(poll, 50) | |
| 273 | + | if e != nil && e != unix.EINTR { | |
| 274 | + | return nil, e | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // ReadText deliberately refuses to block the UI loop. Use ReadTextAsync. | |
| 280 | + | func (c *Clipboard) ReadText(int) ([]byte, error) { | |
| 281 | + | return nil, errors.New("wayland clipboard requires ReadTextAsync") | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (c *Clipboard) WriteText(data []byte) error { | |
| 285 | + | if c == nil || c.device == nil { | |
| 286 | + | return errors.New("clipboard unavailable") | |
| 287 | + | } | |
| 288 | + | c.mu.Lock() | |
| 289 | + | closed := c.closed | |
| 290 | + | c.mu.Unlock() | |
| 291 | + | if closed { | |
| 292 | + | return errors.New("clipboard closed") | |
| 293 | + | } | |
| 294 | + | if len(data) > edit.MaxClipboardBytes { | |
| 295 | + | return errors.New("clipboard too large") | |
| 296 | + | } | |
| 297 | + | serial := c.serial() | |
| 298 | + | if serial == 0 { | |
| 299 | + | return errors.New("no keyboard selection serial") | |
| 300 | + | } | |
| 301 | + | source, err := c.manager.CreateDataSource() | |
| 302 | + | if err != nil { | |
| 303 | + | return err | |
| 304 | + | } | |
| 305 | + | for _, m := range []string{mimeUTF8, mimeX11, mimePlain} { | |
| 306 | + | if err = source.Offer(m); err != nil { | |
| 307 | + | _ = source.Destroy() | |
| 308 | + | return err | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | payload := append([]byte(nil), data...) | |
| 312 | + | source.OnSend(func(m string, fd *wl.OwnedFD) { | |
| 313 | + | n, e := fd.Take() | |
| 314 | + | if e != nil { | |
| 315 | + | return | |
| 316 | + | } | |
| 317 | + | if m != mimeUTF8 && m != mimeX11 && m != mimePlain { | |
| 318 | + | _ = os.NewFile(uintptr(n), "selection").Close() | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | go writeSelection(n, payload) | |
| 322 | + | }) | |
| 323 | + | source.OnCancelled(func() { c.post(ClipboardEvent{kind: "cancelled", source: source}) }) | |
| 324 | + | if err = c.device.SetSelection(source, serial); err != nil { | |
| 325 | + | _ = source.Destroy() | |
| 326 | + | return err | |
| 327 | + | } | |
| 328 | + | if c.source != nil { | |
| 329 | + | _ = c.source.Destroy() | |
| 330 | + | } | |
| 331 | + | c.source = source | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | func writeSelection(fd int, data []byte) { | |
| 335 | + | f := os.NewFile(uintptr(fd), "selection") | |
| 336 | + | defer f.Close() | |
| 337 | + | _ = unix.SetNonblock(fd, true) | |
| 338 | + | deadline := time.Now().Add(transferTimeout) | |
| 339 | + | for len(data) > 0 && time.Now().Before(deadline) { | |
| 340 | + | n, err := unix.Write(fd, data) | |
| 341 | + | if n > 0 { | |
| 342 | + | data = data[n:] | |
| 343 | + | } | |
| 344 | + | if err != nil && err != unix.EAGAIN && err != unix.EINTR { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | if n == 0 || err == unix.EAGAIN { | |
| 348 | + | _, _ = unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}, 50) | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | func (c *Clipboard) Close() { | |
| 353 | + | if c == nil { | |
| 354 | + | return | |
| 355 | + | } | |
| 356 | + | c.mu.Lock() | |
| 357 | + | c.closed = true | |
| 358 | + | if c.stop != nil { | |
| 359 | + | c.stop() | |
| 360 | + | } | |
| 361 | + | c.mu.Unlock() | |
| 362 | + | c.workers.Wait() // reads poll cancellation every 50ms (transfer has a 3s deadline) | |
| 363 | + | c.clearDnD() | |
| 364 | + | if c.offer != nil { | |
| 365 | + | c.destroyOffer(c.offer) | |
| 366 | + | } | |
| 367 | + | for o := range c.pending { | |
| 368 | + | if o != c.offer { | |
| 369 | + | c.destroyOffer(o) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | if c.source != nil { | |
| 373 | + | _ = c.source.Destroy() | |
| 374 | + | } | |
| 375 | + | if c.device != nil { | |
| 376 | + | if c.version >= 2 { | |
| 377 | + | _ = c.device.Release() | |
| 378 | + | } else { | |
| 379 | + | if ctx := c.device.Context(); ctx != nil { | |
| 380 | + | ctx.Unregister(c.device) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | // wl_data_device_manager has no destructor below version 4: sending | |
| 385 | + | // release is a protocol error, so only forget the proxy locally. | |
| 386 | + | if c.manager != nil { | |
| 387 | + | if ctx := c.manager.Context(); ctx != nil { | |
| 388 | + | ctx.Unregister(c.manager) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | } | |
A internal/platform/wayland/clipboard.md +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | # Clipboard adapter | |
| 2 | + | ||
| 3 | + | `Clipboard` is constructed on the session loop with a seat, a function returning | |
| 4 | + | the most recent keyboard enter/key serial, and a **reader-to-loop** event post | |
| 5 | + | function. Deliver each `ClipboardEvent` to `ApplyClipboard` on that loop. | |
| 6 | + | A missing `wl_data_device_manager` returns nil (clipboard unavailable). | |
| 7 | + | Selection offers have independent MIME sets. Only the latest selected offer is | |
| 8 | + | used; superseded offers and old sources are destroyed. Selection write offers | |
| 9 | + | `text/plain;charset=utf-8`, `UTF8_STRING`, and `text/plain`. The same priority | |
| 10 | + | is used when choosing a MIME for reads. FD-bearing send events transfer their | |
| 11 | + | `OwnedFD` to a bounded-time writer; read transfers are bounded at the caller's | |
| 12 | + | limit plus one byte (at most `edit.MaxClipboardBytes+1`) and timed out after | |
| 13 | + | three seconds. Close the adapter before tearing down the data-device manager. | |
| 14 | + | ||
| 15 | + | The synchronous `edit.Clipboard.ReadText` contract would deadlock the UI loop: | |
| 16 | + | Wayland receive requires another process to write a pipe while the UI must | |
| 17 | + | continue dispatching. Therefore `edit.AsyncClipboard` is an additive port. | |
| 18 | + | `ReadTextAsync` starts the transfer without waiting for the peer; its completion | |
| 19 | + | callback runs on a **worker** and must enqueue a value for the next UI frame. | |
| 20 | + | Never mutate the editor in that callback. Call `edit.State.PasteAsync` on the | |
| 21 | + | UI loop to validate UTF-8 and bounds and apply the result. Synchronous | |
| 22 | + | `ReadText` explicitly returns an error rather than blocking. | |
| 23 | + | ||
| 24 | + | Drag-and-drop is unsupported in V1. The data-device enter offer is tracked | |
| 25 | + | separately from selection and discarded on leave, drop, or a subsequent enter; | |
| 26 | + | selection replacement must not destroy an active drag offer. | |
A internal/platform/wayland/clipboard_extra_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 15 | + | "golang.org/x/sys/unix" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestClipboardOfferReplacement(t *testing.T) { | |
| 19 | + | c, d, server := clipboardFixture(t) | |
| 20 | + | first := core.NewDataOffer(d.Context()) | |
| 21 | + | first.SetID(30) | |
| 22 | + | d.Context().Register(first) | |
| 23 | + | second := core.NewDataOffer(d.Context()) | |
| 24 | + | second.SetID(31) | |
| 25 | + | d.Context().Register(second) | |
| 26 | + | for _, o := range []*core.DataOffer{first, second} { | |
| 27 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: o}) | |
| 28 | + | } | |
| 29 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: first, mime: mimePlain}) | |
| 30 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: second, mime: mimeUTF8}) | |
| 31 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: second}) | |
| 32 | + | if c.offer != second || c.bestMIME() != mimeUTF8 { | |
| 33 | + | t.Fatal("latest selection not used") | |
| 34 | + | } | |
| 35 | + | inputRequest(t, server) // old offer destroyed | |
| 36 | + | } | |
| 37 | + | func TestClipboardWriteSendOverSocketpair(t *testing.T) { | |
| 38 | + | c, d, server := clipboardFixture(t) | |
| 39 | + | // WriteText sends create_data_source, three offers and set_selection. | |
| 40 | + | if err := c.WriteText([]byte("hello")); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | // Client writes can coalesce into a single socket read. | |
| 44 | + | _ = server.SetReadDeadline(time.Now().Add(20 * time.Millisecond)) | |
| 45 | + | _, _ = server.Read(make([]byte, 4096)) | |
| 46 | + | source := c.source | |
| 47 | + | r, w, err := os.Pipe() | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | defer r.Close() | |
| 52 | + | defer w.Close() | |
| 53 | + | // Wayland event string uses byte length with trailing NUL, padded to 4. | |
| 54 | + | mime := append([]byte(mimeUTF8), 0) | |
| 55 | + | padded := make([]byte, (len(mime)+3)&^3) | |
| 56 | + | copy(padded, mime) | |
| 57 | + | body := make([]byte, 4+len(padded)) | |
| 58 | + | body[0] = byte(len(mime)) | |
| 59 | + | copy(body[4:], padded) | |
| 60 | + | msg := inputFrame(source.ID(), 1) | |
| 61 | + | msg = append(msg, body...) | |
| 62 | + | msg[6] = byte(len(msg)) | |
| 63 | + | msg[7] = byte(len(msg) >> 8) | |
| 64 | + | inputSend(t, server, msg, int(w.Fd())) | |
| 65 | + | if err = d.Dispatch(); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | data := make([]byte, 5) | |
| 69 | + | _ = r.SetReadDeadline(time.Now().Add(time.Second)) | |
| 70 | + | if _, err = r.Read(data); err != nil || string(data) != "hello" { | |
| 71 | + | t.Fatalf("send %q: %v", data, err) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | func TestClipboardReadLimitAndTimeout(t *testing.T) { | |
| 75 | + | fd := make([]int, 2) | |
| 76 | + | if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | r := os.NewFile(uintptr(fd[0]), "read") | |
| 80 | + | defer r.Close() | |
| 81 | + | defer unix.Close(fd[1]) | |
| 82 | + | if _, err := unix.Write(fd[1], []byte("123456")); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if _, err := readClipboard(context.Background(), r, 5); err == nil { | |
| 86 | + | t.Fatal("oversize accepted") | |
| 87 | + | } | |
| 88 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) | |
| 89 | + | defer cancel() | |
| 90 | + | if _, err := readClipboard(ctx, r, 5); !errors.Is(err, context.DeadlineExceeded) { | |
| 91 | + | t.Fatalf("timeout: %v", err) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestClipboardSendOwnershipAndCancellation(t *testing.T) { | |
| 96 | + | c, d, server := clipboardFixture(t) | |
| 97 | + | if err := c.WriteText([]byte("hello")); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | inputRequest(t, server) | |
| 101 | + | source := c.source | |
| 102 | + | for _, mime := range []string{mimeUTF8, "application/octet-stream"} { | |
| 103 | + | r, w, err := os.Pipe() | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | data := append([]byte(mime), 0) | |
| 108 | + | padded := make([]byte, (len(data)+3)&^3) | |
| 109 | + | copy(padded, data) | |
| 110 | + | msg := inputFrame(source.ID(), 1, uint32(len(data))) | |
| 111 | + | msg = append(msg, padded...) | |
| 112 | + | msg[6], msg[7] = byte(len(msg)), byte(len(msg)>>8) | |
| 113 | + | inputSend(t, server, msg, int(w.Fd())) | |
| 114 | + | if err = d.Dispatch(); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | _ = w.Close() | |
| 118 | + | _ = r.SetReadDeadline(time.Now().Add(time.Second)) | |
| 119 | + | buf := make([]byte, 8) | |
| 120 | + | n, err := r.Read(buf) | |
| 121 | + | if err != nil && err != io.EOF { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | if mime == mimeUTF8 && string(buf[:n]) != "hello" || mime != mimeUTF8 && n != 0 { | |
| 125 | + | t.Fatalf("send %s: %q %v", mime, buf[:n], err) | |
| 126 | + | } | |
| 127 | + | _ = r.Close() | |
| 128 | + | } | |
| 129 | + | inputSend(t, server, inputFrame(source.ID(), 2)) | |
| 130 | + | if err := d.Dispatch(); err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | if c.source != nil { | |
| 134 | + | t.Fatal("cancelled source retained") | |
| 135 | + | } | |
| 136 | + | inputRequest(t, server) // source.destroy | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestClipboardSelectionKeepsDnD(t *testing.T) { | |
| 140 | + | c, d, server := clipboardFixture(t) | |
| 141 | + | drag := core.NewDataOffer(d.Context()) | |
| 142 | + | drag.SetID(70) | |
| 143 | + | d.Context().Register(drag) | |
| 144 | + | selected := core.NewDataOffer(d.Context()) | |
| 145 | + | selected.SetID(71) | |
| 146 | + | d.Context().Register(selected) | |
| 147 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: drag}) | |
| 148 | + | c.ApplyClipboard(ClipboardEvent{kind: "enter", offer: drag}) | |
| 149 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: selected}) | |
| 150 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: selected}) | |
| 151 | + | if c.dnd != drag || c.offer != selected { | |
| 152 | + | t.Fatal("lost active drag or selection") | |
| 153 | + | } | |
| 154 | + | _ = server.SetReadDeadline(time.Now().Add(20 * time.Millisecond)) | |
| 155 | + | if _, err := server.Read(make([]byte, 128)); err == nil { | |
| 156 | + | t.Fatal("active drag destroyed") | |
| 157 | + | } | |
| 158 | + | c.ApplyClipboard(ClipboardEvent{kind: "drop"}) | |
| 159 | + | if c.dnd != nil { | |
| 160 | + | t.Fatal("drop retained drag") | |
| 161 | + | } | |
| 162 | + | inputRequest(t, server) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestClipboardCloseCancelsReadWithoutDelivery(t *testing.T) { | |
| 166 | + | c, d, server := clipboardFixture(t) | |
| 167 | + | offer := core.NewDataOffer(d.Context()) | |
| 168 | + | offer.SetID(80) | |
| 169 | + | d.Context().Register(offer) | |
| 170 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer}) | |
| 171 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain}) | |
| 172 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer}) | |
| 173 | + | delivered := make(chan struct{}, 1) | |
| 174 | + | if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) { delivered <- struct{}{} }); err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | // Keep the compositor's write end open so the read cannot finish before Close. | |
| 178 | + | buf, oob := make([]byte, 256), make([]byte, 128) | |
| 179 | + | _ = server.SetReadDeadline(time.Now().Add(time.Second)) | |
| 180 | + | _, n, _, _, err := server.ReadMsgUnix(buf, oob) | |
| 181 | + | if err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | msgs, err := unix.ParseSocketControlMessage(oob[:n]) | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | for _, msg := range msgs { | |
| 189 | + | fds, err := unix.ParseUnixRights(&msg) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | for _, fd := range fds { | |
| 194 | + | defer unix.Close(fd) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | done := make(chan struct{}) | |
| 198 | + | go func() { c.Close(); close(done) }() | |
| 199 | + | select { | |
| 200 | + | case <-done: | |
| 201 | + | case <-time.After(time.Second): | |
| 202 | + | t.Fatal("Close did not wait for cancelled read") | |
| 203 | + | } | |
| 204 | + | select { | |
| 205 | + | case <-delivered: | |
| 206 | + | t.Fatal("delivered after Close") | |
| 207 | + | default: | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func TestClipboardCloseInsideDelivery(t *testing.T) { | |
| 212 | + | c, d, server := clipboardFixture(t) | |
| 213 | + | offer := core.NewDataOffer(d.Context()) | |
| 214 | + | offer.SetID(81) | |
| 215 | + | d.Context().Register(offer) | |
| 216 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer}) | |
| 217 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain}) | |
| 218 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer}) | |
| 219 | + | done := make(chan struct{}) | |
| 220 | + | if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) { c.Close(); close(done) }); err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | clipboardFinishRead(t, server) | |
| 224 | + | select { | |
| 225 | + | case <-done: | |
| 226 | + | case <-time.After(time.Second): | |
| 227 | + | t.Fatal("Close deadlocked in delivery") | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestClipboardCloseWhileDeliveryBlocks(t *testing.T) { | |
| 232 | + | c, d, server := clipboardFixture(t) | |
| 233 | + | offer := core.NewDataOffer(d.Context()) | |
| 234 | + | offer.SetID(82) | |
| 235 | + | d.Context().Register(offer) | |
| 236 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer}) | |
| 237 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: mimePlain}) | |
| 238 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer}) | |
| 239 | + | entered := make(chan struct{}) | |
| 240 | + | unblock := make(chan struct{}) | |
| 241 | + | callbackDone := make(chan struct{}) | |
| 242 | + | if err := c.ReadTextAsync(context.Background(), 32, func([]byte, error) { | |
| 243 | + | close(entered) | |
| 244 | + | <-unblock | |
| 245 | + | close(callbackDone) | |
| 246 | + | }); err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | clipboardFinishRead(t, server) | |
| 250 | + | select { | |
| 251 | + | case <-entered: | |
| 252 | + | case <-time.After(time.Second): | |
| 253 | + | t.Fatal("delivery not called") | |
| 254 | + | } | |
| 255 | + | done := make(chan struct{}) | |
| 256 | + | go func() { c.Close(); close(done) }() | |
| 257 | + | select { | |
| 258 | + | case <-done: | |
| 259 | + | case <-time.After(time.Second): | |
| 260 | + | t.Fatal("Close blocked on delivery") | |
| 261 | + | } | |
| 262 | + | close(unblock) | |
| 263 | + | select { | |
| 264 | + | case <-callbackDone: | |
| 265 | + | case <-time.After(time.Second): | |
| 266 | + | t.Fatal("callback did not finish") | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Consume receive and close the transferred write FD to complete the read. | |
| 271 | + | func clipboardFinishRead(t *testing.T, server *net.UnixConn) { | |
| 272 | + | t.Helper() | |
| 273 | + | buf, oob := make([]byte, 256), make([]byte, 128) | |
| 274 | + | _ = server.SetReadDeadline(time.Now().Add(time.Second)) | |
| 275 | + | _, n, _, _, err := server.ReadMsgUnix(buf, oob) | |
| 276 | + | if err != nil { | |
| 277 | + | t.Fatal(err) | |
| 278 | + | } | |
| 279 | + | msgs, err := unix.ParseSocketControlMessage(oob[:n]) | |
| 280 | + | if err != nil { | |
| 281 | + | t.Fatal(err) | |
| 282 | + | } | |
| 283 | + | for _, m := range msgs { | |
| 284 | + | fds, err := unix.ParseUnixRights(&m) | |
| 285 | + | if err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | for _, fd := range fds { | |
| 289 | + | _, _ = unix.Write(fd, []byte("ok")) | |
| 290 | + | _ = unix.Close(fd) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
A internal/platform/wayland/clipboard_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefergui/internal/edit" | |
| 15 | + | "github.com/bnema/wlturbo" | |
| 16 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func clipboardFixture(t *testing.T) (*Clipboard, *wlturbo.Display, *net.UnixConn) { | |
| 21 | + | t.Helper() | |
| 22 | + | ca, server := inputPair(t) | |
| 23 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | t.Cleanup(func() { _ = d.Close() }) | |
| 28 | + | inputRequest(t, server) | |
| 29 | + | ctx := d.Context() | |
| 30 | + | manager := core.NewDataDeviceManager(ctx) | |
| 31 | + | if err = d.Registry().Bind(1, core.DataDeviceManagerInterface, 3, manager); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | inputRequest(t, server) | |
| 35 | + | seat := core.NewSeat(ctx) | |
| 36 | + | seat.SetID(20) | |
| 37 | + | ctx.Register(seat) | |
| 38 | + | device, err := manager.GetDataDevice(seat) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | inputRequest(t, server) | |
| 43 | + | c := &Clipboard{manager: manager, device: device, serial: func() uint32 { return 42 }, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)} | |
| 44 | + | c.post = func(e ClipboardEvent) { c.ApplyClipboard(e) } | |
| 45 | + | return c, d, server | |
| 46 | + | } | |
| 47 | + | func TestClipboardReadOverSocketpair(t *testing.T) { | |
| 48 | + | c, d, server := clipboardFixture(t) | |
| 49 | + | offer := core.NewDataOffer(d.Context()) | |
| 50 | + | offer.SetID(30) | |
| 51 | + | d.Context().Register(offer) | |
| 52 | + | c.ApplyClipboard(ClipboardEvent{kind: "offer", offer: offer}) | |
| 53 | + | for _, m := range []string{mimePlain, mimeX11, mimeUTF8} { | |
| 54 | + | c.ApplyClipboard(ClipboardEvent{kind: "mime", offer: offer, mime: m}) | |
| 55 | + | } | |
| 56 | + | c.ApplyClipboard(ClipboardEvent{kind: "selection", offer: offer}) | |
| 57 | + | if c.bestMIME() != mimeUTF8 { | |
| 58 | + | t.Fatal("MIME priority") | |
| 59 | + | } | |
| 60 | + | results := make(chan ClipboardResult, 2) | |
| 61 | + | // The peer reads receive's SCM_RIGHTS and writes to the transferred pipe. | |
| 62 | + | go func() { | |
| 63 | + | b := make([]byte, 512) | |
| 64 | + | oob := make([]byte, 128) | |
| 65 | + | _ = server.SetReadDeadline(time.Now().Add(time.Second)) | |
| 66 | + | _, n, _, _, err := server.ReadMsgUnix(b, oob) | |
| 67 | + | if err != nil { | |
| 68 | + | results <- ClipboardResult{Err: err} | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | messages, err := unix.ParseSocketControlMessage(oob[:n]) | |
| 72 | + | if err != nil { | |
| 73 | + | results <- ClipboardResult{Err: err} | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | for _, m := range messages { | |
| 77 | + | fd, e := unix.ParseUnixRights(&m) | |
| 78 | + | if e != nil || len(fd) == 0 { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | f := os.NewFile(uintptr(fd[0]), "offer-write") | |
| 82 | + | _, e = io.WriteString(f, "hello") | |
| 83 | + | _ = f.Close() | |
| 84 | + | if e != nil { | |
| 85 | + | results <- ClipboardResult{Err: e} | |
| 86 | + | } | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | results <- ClipboardResult{Err: errors.New("missing pipe FD")} | |
| 90 | + | }() | |
| 91 | + | if err := c.ReadTextAsync(context.Background(), edit.MaxClipboardBytes, func(b []byte, e error) { results <- ClipboardResult{Data: b, Err: e} }); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | select { | |
| 95 | + | case result := <-results: | |
| 96 | + | if result.Err != nil || string(result.Data) != "hello" { | |
| 97 | + | t.Fatalf("result: %+v", result) | |
| 98 | + | } | |
| 99 | + | case <-time.After(2 * time.Second): | |
| 100 | + | t.Fatal("read blocked") | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | func TestClipboardBoundAndCancel(t *testing.T) { | |
| 104 | + | r, w, err := os.Pipe() | |
| 105 | + | if err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | defer w.Close() | |
| 109 | + | results := make(chan ClipboardResult, 1) | |
| 110 | + | go func() { data, e := io.ReadAll(io.LimitReader(r, 5)); results <- ClipboardResult{data, e} }() | |
| 111 | + | _, _ = w.Write([]byte("123456")) | |
| 112 | + | select { | |
| 113 | + | case result := <-results: | |
| 114 | + | if len(result.Data) != 5 { | |
| 115 | + | t.Fatal(result) | |
| 116 | + | } | |
| 117 | + | case <-time.After(time.Second): | |
| 118 | + | t.Fatal("limit blocked") | |
| 119 | + | } | |
| 120 | + | state := edit.State{} | |
| 121 | + | if ok, _ := state.PasteAsync([]byte{0xff}, nil); ok { | |
| 122 | + | t.Fatal("accepted invalid UTF-8") | |
| 123 | + | } | |
| 124 | + | if ok, _ := state.PasteAsync(make([]byte, edit.MaxClipboardBytes+1), nil); ok { | |
| 125 | + | t.Fatal("accepted oversized paste") | |
| 126 | + | } | |
| 127 | + | } | |
A internal/platform/wayland/cursor.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/wlturbo/protocol/cursorshape" | |
| 4 | + | ||
| 5 | + | // Cursor shapes NeferGUI maps from CSS `cursor`, in wp_cursor_shape_device_v1 | |
| 6 | + | // enum values. | |
| 7 | + | const ( | |
| 8 | + | CursorDefault = cursorshape.SHAPE_DEFAULT | |
| 9 | + | CursorPointer = cursorshape.SHAPE_POINTER | |
| 10 | + | CursorText = cursorshape.SHAPE_TEXT | |
| 11 | + | CursorNotAllowed = cursorshape.SHAPE_NOT_ALLOWED | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // cursorState is owned by the session loop. The shape device belongs to the | |
| 15 | + | // current wl_pointer; set_shape needs the serial of its latest enter. | |
| 16 | + | type cursorState struct { | |
| 17 | + | device *cursorshape.WpCursorShapeDevice | |
| 18 | + | serial uint32 // latest enter serial, 0 while outside the surface | |
| 19 | + | shape uint32 // last shape sent at serial | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // PointerEntered records the enter serial and resets the sent shape: the | |
| 23 | + | // compositor forgets the client cursor when the pointer leaves the surface. | |
| 24 | + | // An enter from a released pointer (gen not current) is ignored. | |
| 25 | + | func (w *Window) PointerEntered(serial uint32, gen uint64) { | |
| 26 | + | if w.Pointer == nil || gen != w.pointerGen { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | w.cursor.serial, w.cursor.shape = serial, 0 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // PointerLeft marks the pointer outside; shapes are not sent until enter. | |
| 33 | + | func (w *Window) PointerLeft() { w.cursor.serial, w.cursor.shape = 0, 0 } | |
| 34 | + | ||
| 35 | + | // SetCursor requests shape for the pointer over the surface. It is a no-op | |
| 36 | + | // without cursor-shape support, outside the surface, or when unchanged. | |
| 37 | + | func (w *Window) SetCursor(shape uint32) error { | |
| 38 | + | if w.CursorShapes == nil || w.Pointer == nil || w.cursor.serial == 0 || shape == w.cursor.shape { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | if w.cursor.device == nil { | |
| 42 | + | device, err := w.CursorShapes.GetPointer(w.Pointer) | |
| 43 | + | if err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | w.cursor.device = device | |
| 47 | + | } | |
| 48 | + | if err := w.cursor.device.SetShape(w.cursor.serial, shape); err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | w.cursor.shape = shape | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // dropCursorDevice destroys the shape device; call it before its wl_pointer | |
| 56 | + | // is released. The enter serial belongs to that pointer, so it is cleared: | |
| 57 | + | // a replacement pointer sets shapes only after its own enter. | |
| 58 | + | func (w *Window) dropCursorDevice() { | |
| 59 | + | if w.cursor.device != nil { | |
| 60 | + | _ = w.cursor.device.Destroy() | |
| 61 | + | } | |
| 62 | + | w.cursor = cursorState{} | |
| 63 | + | } | |
A internal/platform/wayland/cursor_test.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/wlturbo" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 14 | + | "github.com/bnema/wlturbo/protocol/cursorshape" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // readRequest returns the object ID, opcode and body of the next request. | |
| 18 | + | // Requests can share one socket read, so each is framed by its size field. | |
| 19 | + | func readRequest(t *testing.T, p *net.UnixConn) (uint32, uint16, []byte) { | |
| 20 | + | t.Helper() | |
| 21 | + | _ = p.SetReadDeadline(time.Now().Add(time.Second)) | |
| 22 | + | header := make([]byte, 8) | |
| 23 | + | if _, err := io.ReadFull(p, header); err != nil { | |
| 24 | + | t.Fatalf("read request header: %v", err) | |
| 25 | + | } | |
| 26 | + | size := int(binary.LittleEndian.Uint32(header[4:]) >> 16) | |
| 27 | + | body := make([]byte, size-8) | |
| 28 | + | if _, err := io.ReadFull(p, body); err != nil { | |
| 29 | + | t.Fatalf("read request body: %v", err) | |
| 30 | + | } | |
| 31 | + | return binary.LittleEndian.Uint32(header), uint16(binary.LittleEndian.Uint32(header[4:])), body | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // noRequest asserts the client sent nothing. | |
| 35 | + | func noRequest(t *testing.T, p *net.UnixConn) { | |
| 36 | + | t.Helper() | |
| 37 | + | _ = p.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) | |
| 38 | + | b := make([]byte, 64) | |
| 39 | + | if n, err := p.Read(b); err == nil { | |
| 40 | + | t.Fatalf("unexpected request %x", b[:n]) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestCursorShapeWire(t *testing.T) { | |
| 45 | + | ca, server := inputPair(t) | |
| 46 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | defer d.Close() | |
| 51 | + | inputRequest(t, server) | |
| 52 | + | ctx := d.Context() | |
| 53 | + | pointer := core.NewPointer(ctx) | |
| 54 | + | pointer.SetID(d.AllocateID()) | |
| 55 | + | ctx.Register(pointer) | |
| 56 | + | shapes := cursorshape.NewWpCursorShapeManager(ctx) | |
| 57 | + | if err = d.Registry().Bind(1, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | inputRequest(t, server) | |
| 61 | + | w := &Window{Pointer: pointer, CursorShapes: shapes, pointerGen: 1} | |
| 62 | + | ||
| 63 | + | // Outside the surface nothing is sent. | |
| 64 | + | if err = w.SetCursor(CursorPointer); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | noRequest(t, server) | |
| 68 | + | ||
| 69 | + | // First shape after enter creates the device, then sets the shape at the | |
| 70 | + | // enter serial. | |
| 71 | + | w.PointerEntered(7, 1) | |
| 72 | + | if err = w.SetCursor(CursorPointer); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | id, op, body := readRequest(t, server) | |
| 76 | + | if id != shapes.ID() || op != 1 || binary.LittleEndian.Uint32(body[4:]) != pointer.ID() { | |
| 77 | + | t.Fatalf("get_pointer id=%d op=%d %x", id, op, body) | |
| 78 | + | } | |
| 79 | + | device := binary.LittleEndian.Uint32(body) | |
| 80 | + | id, op, body = readRequest(t, server) | |
| 81 | + | if id != device || op != 1 || binary.LittleEndian.Uint32(body) != 7 || binary.LittleEndian.Uint32(body[4:]) != CursorPointer { | |
| 82 | + | t.Fatalf("set_shape id=%d op=%d %x", id, op, body) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // An unchanged shape is not re-sent; a new one reuses the device. | |
| 86 | + | if err = w.SetCursor(CursorPointer); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | noRequest(t, server) | |
| 90 | + | if err = w.SetCursor(CursorText); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | if id, _, body = readRequest(t, server); id != device || binary.LittleEndian.Uint32(body[4:]) != CursorText { | |
| 94 | + | t.Fatalf("text shape id=%d %x", id, body) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // After leave nothing is sent; re-enter re-sends even the same shape, | |
| 98 | + | // at the new serial. | |
| 99 | + | w.PointerLeft() | |
| 100 | + | if err = w.SetCursor(CursorDefault); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | noRequest(t, server) | |
| 104 | + | w.PointerEntered(9, 1) | |
| 105 | + | if err = w.SetCursor(CursorText); err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | if id, _, body = readRequest(t, server); id != device || binary.LittleEndian.Uint32(body) != 9 || binary.LittleEndian.Uint32(body[4:]) != CursorText { | |
| 109 | + | t.Fatalf("re-enter shape id=%d %x", id, body) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Dropping the device (pointer capability loss) destroys it. | |
| 113 | + | w.dropCursorDevice() | |
| 114 | + | if id, op, _ = readRequest(t, server); id != device || op != 0 { | |
| 115 | + | t.Fatalf("destroy id=%d op=%d", id, op) | |
| 116 | + | } | |
| 117 | + | // The old enter serial went with the pointer: nothing is sent until the | |
| 118 | + | // replacement pointer's own enter, and a late enter from the released | |
| 119 | + | // pointer (older generation) is ignored. | |
| 120 | + | if err = w.SetCursor(CursorPointer); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | noRequest(t, server) | |
| 124 | + | w.pointerGen = 2 | |
| 125 | + | w.PointerEntered(11, 1) | |
| 126 | + | if err = w.SetCursor(CursorPointer); err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | noRequest(t, server) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Without the global, SetCursor is a silent no-op. | |
| 133 | + | func TestCursorShapeUnsupported(t *testing.T) { | |
| 134 | + | w := &Window{Pointer: &core.Pointer{}} | |
| 135 | + | w.PointerEntered(1, 0) | |
| 136 | + | if err := w.SetCursor(CursorPointer); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | } | |
A internal/platform/wayland/events.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 8 | + | "net" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type EventKind uint8 | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | ConfigureSize EventKind = iota + 1 | |
| 16 | + | ConfigureSerial | |
| 17 | + | PreferredScale | |
| 18 | + | BufferScale | |
| 19 | + | CloseEvent | |
| 20 | + | FrameEvent | |
| 21 | + | BufferRelease | |
| 22 | + | TransportError | |
| 23 | + | SeatCapabilities | |
| 24 | + | SeatRemoved | |
| 25 | + | InputFrame | |
| 26 | + | InputKeymap | |
| 27 | + | InputFocusIn | |
| 28 | + | InputFocusOut | |
| 29 | + | InputKey | |
| 30 | + | InputModifiers | |
| 31 | + | InputRepeatInfo | |
| 32 | + | FeedbackError | |
| 33 | + | FeedbackMainDevice | |
| 34 | + | FeedbackTranche | |
| 35 | + | FeedbackFormats | |
| 36 | + | ClipboardInput | |
| 37 | + | IMEInput | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | type Event struct { | |
| 41 | + | Kind EventKind | |
| 42 | + | Width, Height int32 | |
| 43 | + | Serial, Scale uint32 | |
| 44 | + | Factor int32 | |
| 45 | + | BufferID uint64 | |
| 46 | + | Err error | |
| 47 | + | Device uint64 | |
| 48 | + | Tranche, Formats []linuxdmabuf.FormatEntry | |
| 49 | + | Inputs []Input | |
| 50 | + | Clipboard ClipboardEvent | |
| 51 | + | IME IMEEvent | |
| 52 | + | Capabilities, Format, Size, Code uint32 | |
| 53 | + | FD int // owned by ApplyInput or CloseEventFD when Kind == InputKeymap | |
| 54 | + | Pressed bool | |
| 55 | + | Mods [4]uint32 | |
| 56 | + | Rate, Delay int32 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // StartReader launches exactly one blocking Wayland reader. Registered callbacks | |
| 60 | + | // only send typed values to Events; Apply is called by the session loop alone. | |
| 61 | + | func (w *Window) StartReader() <-chan Event { | |
| 62 | + | w.readerOnce.Do(func() { | |
| 63 | + | w.readerStarted.Store(true) | |
| 64 | + | w.readerDone = make(chan struct{}) | |
| 65 | + | go func() { | |
| 66 | + | defer close(w.readerDone) | |
| 67 | + | for { | |
| 68 | + | err := w.Display.Dispatch() | |
| 69 | + | if err != nil { | |
| 70 | + | select { | |
| 71 | + | case <-w.readerStop: | |
| 72 | + | return | |
| 73 | + | case w.events <- Event{Kind: TransportError, Err: err}: | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | }() | |
| 79 | + | }) | |
| 80 | + | return w.events | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Events exposes the reader's typed event queue to the session owner. | |
| 84 | + | func (w *Window) Events() <-chan Event { return w.events } | |
| 85 | + | ||
| 86 | + | // PostClipboard and PostIME enqueue transport callbacks for the owner loop. | |
| 87 | + | func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) } | |
| 88 | + | func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) } | |
| 89 | + | ||
| 90 | + | func (w *Window) post(ev Event) { | |
| 91 | + | select { | |
| 92 | + | case <-w.readerStop: | |
| 93 | + | return | |
| 94 | + | case w.events <- ev: | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // postFD closes an undeliverable keymap descriptor rather than leaking it. | |
| 99 | + | func (w *Window) postFD(ev Event) { | |
| 100 | + | select { | |
| 101 | + | case <-w.readerStop: | |
| 102 | + | CloseEventFD(ev) | |
| 103 | + | case w.events <- ev: | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // BufferReleased is safe to call from the reader callback: it never touches | |
| 108 | + | // Pool, Session, or Vulkan. The serial loop records the optional event. | |
| 109 | + | func (w *Window) BufferReleased(id uint64) { | |
| 110 | + | w.post(Event{Kind: BufferRelease, BufferID: id}) | |
| 111 | + | } | |
| 112 | + | func (w *Window) Apply(ev Event) error { | |
| 113 | + | switch ev.Kind { | |
| 114 | + | case ConfigureSize: | |
| 115 | + | if ev.Width > 0 && ev.Height > 0 && (w.Width != ev.Width || w.Height != ev.Height) { | |
| 116 | + | w.Width, w.Height, w.Dirty = ev.Width, ev.Height, true | |
| 117 | + | } | |
| 118 | + | case ConfigureSerial: | |
| 119 | + | if err := w.XdgSurface.AckConfigure(ev.Serial); err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | w.Configured, w.FrameReady = true, true | |
| 123 | + | case PreferredScale: | |
| 124 | + | if ev.Scale == 0 { | |
| 125 | + | return fmt.Errorf("invalid preferred scale zero") | |
| 126 | + | } | |
| 127 | + | if ev.Scale%120 != 0 && w.Viewport == nil { | |
| 128 | + | return &CapabilityError{Name: "wp_viewporter", Cause: fmt.Errorf("fractional scale %d/120 requires viewporter", ev.Scale)} | |
| 129 | + | } | |
| 130 | + | if scale := float64(ev.Scale) / 120; w.Scale != scale { | |
| 131 | + | w.Scale = scale | |
| 132 | + | w.Dirty = true | |
| 133 | + | } | |
| 134 | + | case BufferScale: | |
| 135 | + | if w.Fractional == nil && ev.Factor > 0 && w.Scale != float64(ev.Factor) { | |
| 136 | + | w.Scale = float64(ev.Factor) | |
| 137 | + | w.Dirty = true | |
| 138 | + | } | |
| 139 | + | case CloseEvent: | |
| 140 | + | w.Closed = true | |
| 141 | + | case FrameEvent: | |
| 142 | + | w.FrameReady = true | |
| 143 | + | case FeedbackError: | |
| 144 | + | if w.feedbackErr == nil { | |
| 145 | + | w.feedbackErr = ev.Err | |
| 146 | + | } | |
| 147 | + | return ev.Err | |
| 148 | + | case FeedbackMainDevice: | |
| 149 | + | w.MainDevice = ev.Device | |
| 150 | + | case FeedbackTranche: | |
| 151 | + | w.Tranches = append(w.Tranches, ev.Tranche) | |
| 152 | + | case FeedbackFormats: | |
| 153 | + | w.Formats = ev.Formats | |
| 154 | + | case SeatCapabilities, SeatRemoved: | |
| 155 | + | return w.ApplySeat(ev) | |
| 156 | + | case ClipboardInput, IMEInput: | |
| 157 | + | // These are applied by the session owner after Window.Apply. | |
| 158 | + | case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo: | |
| 159 | + | if w.inputAdapter == nil { | |
| 160 | + | var err error | |
| 161 | + | w.inputAdapter, err = NewInputAdapter("") | |
| 162 | + | if err != nil { | |
| 163 | + | CloseEventFD(ev) | |
| 164 | + | return err | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | inputs, err := w.inputAdapter.ApplyInput(ev, time.Now()) | |
| 168 | + | w.queueInput(inputs) | |
| 169 | + | return err | |
| 170 | + | case BufferRelease: // consumed by the session owner; no pixel ownership implication | |
| 171 | + | case TransportError: | |
| 172 | + | if ev.Err != nil { | |
| 173 | + | return ev.Err | |
| 174 | + | } | |
| 175 | + | return net.ErrClosed | |
| 176 | + | default: | |
| 177 | + | return fmt.Errorf("unknown Wayland event %d", ev.Kind) | |
| 178 | + | } | |
| 179 | + | return nil | |
| 180 | + | } | |
A internal/platform/wayland/harness_input_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefergui/internal/harness" | |
| 15 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 16 | + | "github.com/bnema/wlturbo/protocol/xdgshell" | |
| 17 | + | "github.com/bnema/wlturbo/wl" | |
| 18 | + | "golang.org/x/sys/unix" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // The child uses a plain shm toplevel so the test does not depend on a GPU | |
| 22 | + | // renderer. The parent runs the pinned compositor with its real input script. | |
| 23 | + | func TestHarnessInput(t *testing.T) { | |
| 24 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 25 | + | t.Skip("set NEFERGUI_HARNESS=1 for NeferWL input injection") | |
| 26 | + | } | |
| 27 | + | script := filepath.Join(t.TempDir(), "input.txt") | |
| 28 | + | if err := os.WriteFile(script, []byte("sleep 2s\nmove 100 100\nsleep 200ms\nclick left\nkey a\nsleep 500ms\n"), 0600); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | root := filepath.Join(t.TempDir(), "artifacts") | |
| 32 | + | result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: root, Input: script, Timeout: 25 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessInputClient$", "-test.v"}}) | |
| 33 | + | if code != harness.Pass { | |
| 34 | + | t.Fatalf("harness code=%d status=%s error=%s artifacts=%s", code, result.Status, result.Error, root) | |
| 35 | + | } | |
| 36 | + | data, err := os.ReadFile(result.Artifacts["client_stdout"]) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | for _, want := range []string{"INPUT motion", "INPUT press", "INPUT release", "INPUT key a"} { | |
| 41 | + | if !strings.Contains(string(data), want) { | |
| 42 | + | t.Fatalf("missing %s in client output:\n%s", want, data) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestHarnessInputClient(t *testing.T) { | |
| 48 | + | if os.Getenv("NEFERGUI_DEBUG_DIR") == "" { | |
| 49 | + | t.Skip("harness child only") | |
| 50 | + | } | |
| 51 | + | d, err := wl.Connect("") | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | defer d.Close() | |
| 56 | + | if err = d.Roundtrip(); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | ctx := d.Context() | |
| 60 | + | compositor := core.NewCompositor(ctx) | |
| 61 | + | if _, err = d.Registry().BindNegotiated(core.CompositorInterface, 6, compositor); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | shell := xdgshell.NewXdgWmBase(ctx) | |
| 65 | + | if _, err = d.Registry().BindNegotiated(xdgshell.XdgWmBaseInterface, 6, shell); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | shm := core.NewShm(ctx) | |
| 69 | + | if _, err = d.Registry().BindNegotiated(core.ShmInterface, 1, shm); err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | surface, err := compositor.CreateSurface() | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | xdg, err := shell.GetXdgSurface(surface) | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | if _, err = xdg.GetToplevel(); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | configured := false | |
| 84 | + | xdg.OnConfigure(func(serial uint32) { | |
| 85 | + | if e := xdg.AckConfigure(serial); e != nil { | |
| 86 | + | t.Error(e) | |
| 87 | + | } | |
| 88 | + | configured = true | |
| 89 | + | }) | |
| 90 | + | if err = surface.Commit(); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | for !configured { | |
| 94 | + | if err = d.Dispatch(); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | fd, err := unix.MemfdCreate("nefergui-harness-input", unix.MFD_CLOEXEC) | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | if err = unix.Ftruncate(fd, 400*300*4); err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | pool, err := shm.CreatePool(fd, 400*300*4) // consumes fd on success | |
| 106 | + | if err != nil { | |
| 107 | + | _ = unix.Close(fd) | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | buffer, err := pool.CreateBuffer(0, 400, 300, 400*4, 0) // wl_shm XRGB8888 | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | if err = surface.Attach(buffer, 0, 0); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if err = surface.DamageBuffer(0, 0, 400, 300); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if err = surface.Commit(); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | w := &Window{Display: d, Surface: surface, events: make(chan Event, 128), readerStop: make(chan struct{})} | |
| 124 | + | if err = w.BindSeat(); err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | adapter, err := NewInputAdapter("en_US.UTF-8") | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | defer adapter.Close() | |
| 132 | + | deadline := time.After(6 * time.Second) | |
| 133 | + | seen := map[string]bool{} | |
| 134 | + | for !(seen["motion"] && seen["press"] && seen["release"] && seen["key a"]) { | |
| 135 | + | select { | |
| 136 | + | case <-deadline: | |
| 137 | + | t.Fatalf("timed out waiting for injected input: %v", seen) | |
| 138 | + | default: | |
| 139 | + | } | |
| 140 | + | if err = d.Dispatch(); err != nil { | |
| 141 | + | t.Fatal(err) | |
| 142 | + | } | |
| 143 | + | for len(w.events) > 0 { | |
| 144 | + | ev := <-w.events | |
| 145 | + | switch ev.Kind { | |
| 146 | + | case SeatCapabilities, SeatRemoved: | |
| 147 | + | if err = w.ApplySeat(ev); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo: | |
| 151 | + | inputs, e := adapter.ApplyInput(ev, time.Now()) | |
| 152 | + | if e != nil { | |
| 153 | + | t.Fatal(e) | |
| 154 | + | } | |
| 155 | + | for _, in := range inputs { | |
| 156 | + | kind := in.Kind | |
| 157 | + | if kind == "key" && in.Key.Pressed && in.Key.Text == "a" { | |
| 158 | + | kind = "key a" | |
| 159 | + | } | |
| 160 | + | if kind == "motion" || kind == "press" || kind == "release" || kind == "key a" { | |
| 161 | + | seen[kind] = true | |
| 162 | + | fmt.Println("INPUT", kind) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
A internal/platform/wayland/ime.go +329 -0
| @@ -0,0 +1,329 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "math" | |
| 8 | + | "unicode/utf8" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefergui/internal/edit" | |
| 11 | + | "github.com/bnema/wlturbo" | |
| 12 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/textinput" | |
| 14 | + | "github.com/bnema/wlturbo/wl" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // IMEEvent is posted from the Wayland reader to the session loop. Only a done | |
| 18 | + | // event produces an edit.IMEBatch; intermediate protocol events are buffered. | |
| 19 | + | type IMEEvent struct { | |
| 20 | + | Kind string | |
| 21 | + | Surface uint32 | |
| 22 | + | Text string | |
| 23 | + | Begin, End int32 | |
| 24 | + | Before, After uint32 | |
| 25 | + | Serial uint32 | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // IME is loop-owned. On errors in the edit.IME interface, Err records the | |
| 29 | + | // first protocol send failure; the session owner should inspect it each frame. | |
| 30 | + | type IME struct { | |
| 31 | + | manager *textinput.TextInputManagerV3 | |
| 32 | + | input *textinput.TextInputV3 | |
| 33 | + | surface uint32 | |
| 34 | + | post func(IMEEvent) | |
| 35 | + | batch edit.IMEBatch | |
| 36 | + | commits uint32 | |
| 37 | + | entered, enabled bool | |
| 38 | + | requested, synced bool | |
| 39 | + | pendingSurround string | |
| 40 | + | hasSurround bool | |
| 41 | + | pendingCursor, pendingAnchor int | |
| 42 | + | pendingContent bool | |
| 43 | + | password, multiline bool | |
| 44 | + | pendingRect bool | |
| 45 | + | rect [4]int32 | |
| 46 | + | Err error | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var _ edit.IME = (*IME)(nil) | |
| 50 | + | ||
| 51 | + | // NewIME returns nil if text-input-v3 is not announced; keyboard remains usable. | |
| 52 | + | func NewIME(d *wl.Display, seat *core.Seat, surface *core.Surface, post func(IMEEvent)) (*IME, error) { | |
| 53 | + | if d == nil || seat == nil || surface == nil { | |
| 54 | + | return nil, nil | |
| 55 | + | } | |
| 56 | + | manager := textinput.NewTextInputManagerV3(d.Context()) | |
| 57 | + | if _, err := d.Registry().BindNegotiated(textinput.TextInputManagerV3Interface, 2, manager); err != nil { | |
| 58 | + | d.Context().Unregister(manager) | |
| 59 | + | if errors.Is(err, wlturbo.ErrGlobalNotFound) { | |
| 60 | + | return nil, nil | |
| 61 | + | } | |
| 62 | + | return nil, err | |
| 63 | + | } | |
| 64 | + | input, err := manager.GetTextInput(seat) | |
| 65 | + | if err != nil { | |
| 66 | + | _ = manager.Destroy() | |
| 67 | + | return nil, err | |
| 68 | + | } | |
| 69 | + | m := &IME{manager: manager, input: input, surface: surface.ID(), post: post} | |
| 70 | + | input.OnEnter(func(id uint32) { post(IMEEvent{Kind: "enter", Surface: id}) }) | |
| 71 | + | input.OnLeave(func(id uint32) { post(IMEEvent{Kind: "leave", Surface: id}) }) | |
| 72 | + | input.OnPreeditString(func(s string, b, e int32) { post(IMEEvent{Kind: "preedit", Text: s, Begin: b, End: e}) }) | |
| 73 | + | input.OnCommitString(func(s string) { post(IMEEvent{Kind: "commit", Text: s}) }) | |
| 74 | + | input.OnDeleteSurroundingText(func(b, a uint32) { post(IMEEvent{Kind: "delete", Before: b, After: a}) }) | |
| 75 | + | input.OnDone(func(serial uint32) { post(IMEEvent{Kind: "done", Serial: serial}) }) | |
| 76 | + | return m, nil | |
| 77 | + | } | |
| 78 | + | func (m *IME) send(err error) { | |
| 79 | + | if err != nil && m.Err == nil { | |
| 80 | + | m.Err = err | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | func (m *IME) commit() { | |
| 84 | + | if m == nil || m.input == nil { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | if err := m.input.Commit(); err != nil { | |
| 88 | + | m.send(err) | |
| 89 | + | } else { | |
| 90 | + | m.commits++ | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | func (m *IME) Enable() { | |
| 94 | + | if m == nil { | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | m.requested = true | |
| 98 | + | if !m.entered || m.enabled { | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | m.send(m.input.Enable()) | |
| 102 | + | if m.Err == nil { | |
| 103 | + | m.enabled = true | |
| 104 | + | m.synced = true | |
| 105 | + | // Initial state is double-buffered with enable in one commit. | |
| 106 | + | if m.pendingContent { | |
| 107 | + | m.pendingContent = false | |
| 108 | + | m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose())) | |
| 109 | + | } | |
| 110 | + | if m.hasSurround { | |
| 111 | + | m.hasSurround = false | |
| 112 | + | m.send(m.input.SetSurroundingText(m.pendingSurround, int32(m.pendingCursor), int32(m.pendingAnchor))) | |
| 113 | + | } | |
| 114 | + | if m.pendingRect { | |
| 115 | + | m.pendingRect = false | |
| 116 | + | m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3])) | |
| 117 | + | } | |
| 118 | + | if m.Err == nil { | |
| 119 | + | m.commit() | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | func (m *IME) Disable() { | |
| 124 | + | if m == nil { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | m.requested = false | |
| 128 | + | if !m.enabled { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | m.send(m.input.Disable()) | |
| 132 | + | m.enabled = false | |
| 133 | + | m.synced = true | |
| 134 | + | m.commit() | |
| 135 | + | m.batch = edit.IMEBatch{} | |
| 136 | + | } | |
| 137 | + | func (m *IME) Surrounding(text string, cursor, anchor int) { | |
| 138 | + | if m == nil { | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | text, cursor, anchor = surroundingWindow(text, cursor, anchor) | |
| 142 | + | if !m.enabled || !m.synced { | |
| 143 | + | m.pendingSurround, m.pendingCursor, m.pendingAnchor = text, cursor, anchor | |
| 144 | + | m.hasSurround = true | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | m.send(m.input.SetSurroundingText(text, int32(cursor), int32(anchor))) | |
| 148 | + | m.commit() | |
| 149 | + | } | |
| 150 | + | func (m *IME) CursorRect(x, y, w, h float64) { | |
| 151 | + | if m == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | if !validRect(x, y, w, h) { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | m.rect = [4]int32{int32(math.Round(x)), int32(math.Round(y)), int32(math.Round(w)), int32(math.Round(h))} | |
| 158 | + | if !m.enabled || !m.synced { | |
| 159 | + | m.pendingRect = true | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3])) | |
| 163 | + | m.commit() | |
| 164 | + | } | |
| 165 | + | func validRect(x, y, w, h float64) bool { | |
| 166 | + | for _, v := range []float64{x, y, w, h} { | |
| 167 | + | if math.IsNaN(v) || math.IsInf(v, 0) || v < -math.MaxInt32 || v > math.MaxInt32 { | |
| 168 | + | return false | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | return w >= 0 && h >= 0 | |
| 172 | + | } | |
| 173 | + | func (m *IME) ContentType(password, multiline bool) { | |
| 174 | + | if m == nil { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | if !m.enabled || !m.synced { | |
| 178 | + | m.pendingContent = true | |
| 179 | + | m.password, m.multiline = password, multiline | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | m.password, m.multiline = password, multiline | |
| 183 | + | m.send(m.input.SetContentType(m.contentHint(), m.contentPurpose())) | |
| 184 | + | m.commit() | |
| 185 | + | } | |
| 186 | + | func (m *IME) contentHint() uint32 { | |
| 187 | + | hint := uint32(textinput.CONTENT_HINT_NONE) | |
| 188 | + | if m.password { | |
| 189 | + | return textinput.CONTENT_HINT_SENSITIVE_DATA | textinput.CONTENT_HINT_HIDDEN_TEXT | |
| 190 | + | } | |
| 191 | + | if m.multiline { | |
| 192 | + | return textinput.CONTENT_HINT_MULTILINE | |
| 193 | + | } | |
| 194 | + | return hint | |
| 195 | + | } | |
| 196 | + | func (m *IME) contentPurpose() uint32 { | |
| 197 | + | if m.password { | |
| 198 | + | return textinput.CONTENT_PURPOSE_PASSWORD | |
| 199 | + | } | |
| 200 | + | return textinput.CONTENT_PURPOSE_NORMAL | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // surroundingWindow keeps a UTF-8 aligned 4000-byte window including the | |
| 204 | + | // cursor and as much of the selection as possible, centered near the cursor. | |
| 205 | + | func surroundingWindow(s string, cursor, anchor int) (string, int, int) { | |
| 206 | + | if !utf8.ValidString(s) { | |
| 207 | + | s = "" | |
| 208 | + | } | |
| 209 | + | cursor = max(0, min(cursor, len(s))) | |
| 210 | + | anchor = max(0, min(anchor, len(s))) | |
| 211 | + | for cursor > 0 && cursor < len(s) && !utf8.RuneStart(s[cursor]) { | |
| 212 | + | cursor-- | |
| 213 | + | } | |
| 214 | + | for anchor > 0 && anchor < len(s) && !utf8.RuneStart(s[anchor]) { | |
| 215 | + | anchor-- | |
| 216 | + | } | |
| 217 | + | if len(s) <= 4000 { | |
| 218 | + | return s, cursor, anchor | |
| 219 | + | } | |
| 220 | + | start := max(0, cursor-2000) | |
| 221 | + | if anchor < cursor && cursor-anchor <= 4000 { | |
| 222 | + | start = min(start, anchor) | |
| 223 | + | } | |
| 224 | + | if anchor > cursor && anchor-cursor <= 4000 { | |
| 225 | + | start = max(start, anchor-4000) | |
| 226 | + | } | |
| 227 | + | start = min(start, len(s)-4000) | |
| 228 | + | for start < len(s) && !utf8.RuneStart(s[start]) { | |
| 229 | + | start++ | |
| 230 | + | } | |
| 231 | + | end := min(len(s), start+4000) | |
| 232 | + | for end > start && end < len(s) && !utf8.RuneStart(s[end]) { | |
| 233 | + | end-- | |
| 234 | + | } | |
| 235 | + | cursor -= start | |
| 236 | + | anchor = max(0, min(anchor-start, end-start)) | |
| 237 | + | return s[start:end], cursor, anchor | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // ApplyIME buffers reader events until done. The serial is the compositor's | |
| 241 | + | // count of client commit requests, not a keyboard serial. Per text-input-v3, | |
| 242 | + | // even a mismatched serial must apply the edit batch as normal; it only | |
| 243 | + | // prohibits changing protocol state until a matching done is received. | |
| 244 | + | func (m *IME) ApplyIME(ev IMEEvent) (edit.IMEBatch, bool) { | |
| 245 | + | if m == nil { | |
| 246 | + | return edit.IMEBatch{}, false | |
| 247 | + | } | |
| 248 | + | switch ev.Kind { | |
| 249 | + | case "enter": | |
| 250 | + | if ev.Surface == m.surface { | |
| 251 | + | m.entered = true | |
| 252 | + | m.synced = true | |
| 253 | + | m.batch = edit.IMEBatch{} | |
| 254 | + | if m.requested { | |
| 255 | + | m.enabled = false | |
| 256 | + | m.Enable() | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | case "leave": | |
| 260 | + | if ev.Surface == m.surface { | |
| 261 | + | m.entered = false | |
| 262 | + | m.enabled = false | |
| 263 | + | m.synced = false | |
| 264 | + | m.batch = edit.IMEBatch{} | |
| 265 | + | } | |
| 266 | + | case "preedit": | |
| 267 | + | if !m.entered || !m.enabled { | |
| 268 | + | return edit.IMEBatch{}, false | |
| 269 | + | } | |
| 270 | + | m.batch.Preedit = ev.Text | |
| 271 | + | m.batch.Begin = int(ev.Begin) | |
| 272 | + | m.batch.End = int(ev.End) | |
| 273 | + | case "commit": | |
| 274 | + | if !m.entered || !m.enabled { | |
| 275 | + | return edit.IMEBatch{}, false | |
| 276 | + | } | |
| 277 | + | m.batch.Commit = ev.Text | |
| 278 | + | case "delete": | |
| 279 | + | if !m.entered || !m.enabled { | |
| 280 | + | return edit.IMEBatch{}, false | |
| 281 | + | } | |
| 282 | + | m.batch.DeleteBefore = int(min(ev.Before, 4000)) | |
| 283 | + | m.batch.DeleteAfter = int(min(ev.After, 4000)) | |
| 284 | + | case "done": | |
| 285 | + | batch := m.batch | |
| 286 | + | m.batch = edit.IMEBatch{} | |
| 287 | + | if !m.entered || !m.enabled { | |
| 288 | + | return edit.IMEBatch{}, false | |
| 289 | + | } | |
| 290 | + | m.synced = ev.Serial == m.commits | |
| 291 | + | if m.synced { | |
| 292 | + | m.flushPending() | |
| 293 | + | } | |
| 294 | + | return batch, true | |
| 295 | + | } | |
| 296 | + | return edit.IMEBatch{}, false | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // flushPending sends state only after a matching done. Mismatched done batches | |
| 300 | + | // still reach the editor, but do not modify the text-input object's state. | |
| 301 | + | func (m *IME) flushPending() { | |
| 302 | + | if m.pendingContent { | |
| 303 | + | m.pendingContent = false | |
| 304 | + | m.ContentType(m.password, m.multiline) | |
| 305 | + | } | |
| 306 | + | if m.hasSurround { | |
| 307 | + | s, c, a := m.pendingSurround, m.pendingCursor, m.pendingAnchor | |
| 308 | + | m.hasSurround = false | |
| 309 | + | m.Surrounding(s, c, a) | |
| 310 | + | } | |
| 311 | + | if m.pendingRect { | |
| 312 | + | m.pendingRect = false | |
| 313 | + | m.send(m.input.SetCursorRectangle(m.rect[0], m.rect[1], m.rect[2], m.rect[3])) | |
| 314 | + | m.commit() | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | func (m *IME) Close() { | |
| 318 | + | if m == nil { | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | if m.input != nil { | |
| 322 | + | _ = m.input.Destroy() | |
| 323 | + | m.input = nil | |
| 324 | + | } | |
| 325 | + | if m.manager != nil { | |
| 326 | + | _ = m.manager.Destroy() | |
| 327 | + | m.manager = nil | |
| 328 | + | } | |
| 329 | + | } | |
A internal/platform/wayland/ime.md +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | # text-input v3 adapter | |
| 2 | + | ||
| 3 | + | `NewIME` optionally binds `zwp_text_input_manager_v3` and creates one | |
| 4 | + | text-input object per seat. `nil` means unavailable: ordinary xkb text remains | |
| 5 | + | active. A session posts reader callbacks as `IMEEvent`s, invokes `ApplyIME` on | |
| 6 | + | the UI loop, and passes only the completed `edit.IMEBatch` to the editor. | |
| 7 | + | Ordinary xkb text stays active while text-input is enabled, so enabling it | |
| 8 | + | without a running input method does not block typing. An input method that | |
| 9 | + | grabs the keyboard receives keys instead of `wl_keyboard`. For an input method | |
| 10 | + | that does not grab, keys reaching `wl_keyboard` are typed except while its | |
| 11 | + | preedit is shown; the input method owns that text until it commits. | |
| 12 | + | `Enable` requested before `enter` is deferred until the | |
| 13 | + | surface gains text-input focus; leave drops the pending composition. | |
| 14 | + | ||
| 15 | + | Every successfully sent `zwp_text_input_v3.commit` increments the per-object | |
| 16 | + | commit count. The protocol's `done.serial` is this **count**, not the keyboard | |
| 17 | + | serial. The text-input-v3 XML says a mismatched done still applies its preedit, | |
| 18 | + | commit and delete batch normally, but the client must not modify the protocol | |
| 19 | + | object's current state; pending surrounding/content/rectangle updates are | |
| 20 | + | held until a matching done arrives. `ApplyIME` implements this rule. Requests | |
| 21 | + | are double-buffered by protocol commits. The cursor rectangle is surface-local | |
| 22 | + | logical pixels; with negotiated v2 it is applied on the next surface commit. | |
| 23 | + | A password sends sensitive+hidden hints, password purpose, and empty | |
| 24 | + | surrounding text (the editor owns that privacy decision). | |
| 25 | + | ||
| 26 | + | Surrounding text is limited to 4000 UTF-8 bytes and clipped around the cursor | |
| 27 | + | at rune boundaries. Reader callbacks never touch editor state. NeferWL does | |
| 28 | + | not expose text-input-v3; tests use a socketpair fake server only. | |
A internal/platform/wayland/ime_test.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/wlturbo" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 14 | + | "github.com/bnema/wlturbo/protocol/textinput" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestSurroundingUTF8Window(t *testing.T) { | |
| 18 | + | s := strings.Repeat("é", 3000) | |
| 19 | + | for _, pos := range []int{0, 1, 2000, 3999, len(s)} { | |
| 20 | + | text, cursor, anchor := surroundingWindow(s, pos, pos) | |
| 21 | + | if len(text) > 4000 || !strings.Contains(s, text) || cursor < 0 || cursor > len(text) || anchor != cursor { | |
| 22 | + | t.Fatalf("window at %d: len=%d cursor=%d", pos, len(text), cursor) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | func imeStringFrame(id uint32, opcode uint16, s string, tail ...uint32) []byte { | |
| 27 | + | data := append([]byte(s), 0) | |
| 28 | + | padded := make([]byte, (len(data)+3)&^3) | |
| 29 | + | copy(padded, data) | |
| 30 | + | frame := inputFrame(id, opcode, uint32(len(data))) | |
| 31 | + | frame = append(frame, padded...) | |
| 32 | + | for _, v := range tail { | |
| 33 | + | b := make([]byte, 4) | |
| 34 | + | binary.LittleEndian.PutUint32(b, v) | |
| 35 | + | frame = append(frame, b...) | |
| 36 | + | } | |
| 37 | + | binary.LittleEndian.PutUint32(frame[4:], uint32(len(frame))<<16|uint32(opcode)) | |
| 38 | + | return frame | |
| 39 | + | } | |
| 40 | + | func TestIMEBatchSerialOverSocketpair(t *testing.T) { | |
| 41 | + | ca, server := inputPair(t) | |
| 42 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | defer d.Close() | |
| 47 | + | inputRequest(t, server) | |
| 48 | + | manager := textinput.NewTextInputManagerV3(d.Context()) | |
| 49 | + | if err = d.Registry().Bind(1, textinput.TextInputManagerV3Interface, 2, manager); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | inputRequest(t, server) | |
| 53 | + | seat := core.NewSeat(d.Context()) | |
| 54 | + | seat.SetID(20) | |
| 55 | + | d.Context().Register(seat) | |
| 56 | + | surface := core.NewSurface(d.Context()) | |
| 57 | + | surface.SetID(30) | |
| 58 | + | d.Context().Register(surface) | |
| 59 | + | input, err := manager.GetTextInput(seat) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | inputRequest(t, server) | |
| 64 | + | events := make(chan IMEEvent, 10) | |
| 65 | + | m := &IME{input: input, surface: surface.ID(), post: func(e IMEEvent) { events <- e }} | |
| 66 | + | input.OnEnter(func(id uint32) { m.post(IMEEvent{Kind: "enter", Surface: id}) }) | |
| 67 | + | input.OnPreeditString(func(s string, b, e int32) { m.post(IMEEvent{Kind: "preedit", Text: s, Begin: b, End: e}) }) | |
| 68 | + | input.OnCommitString(func(s string) { m.post(IMEEvent{Kind: "commit", Text: s}) }) | |
| 69 | + | input.OnDeleteSurroundingText(func(b, a uint32) { m.post(IMEEvent{Kind: "delete", Before: b, After: a}) }) | |
| 70 | + | input.OnDone(func(serial uint32) { m.post(IMEEvent{Kind: "done", Serial: serial}) }) | |
| 71 | + | m.Enable() // requests before surface enter are remembered | |
| 72 | + | inputSend(t, server, inputFrame(input.ID(), 0, surface.ID())) | |
| 73 | + | if err = d.Dispatch(); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | m.ApplyIME(<-events) | |
| 77 | + | if m.commits != 1 || !m.enabled { | |
| 78 | + | t.Fatalf("enable %d %v", m.commits, m.enabled) | |
| 79 | + | } | |
| 80 | + | inputRequest(t, server) // enable; commit might be in the same read | |
| 81 | + | for _, msg := range [][]byte{ | |
| 82 | + | imeStringFrame(input.ID(), 2, "é", 0, 2), | |
| 83 | + | imeStringFrame(input.ID(), 3, "x"), | |
| 84 | + | inputFrame(input.ID(), 4, 1, 0), | |
| 85 | + | inputFrame(input.ID(), 5, 0), // mismatched serial MUST still apply edit | |
| 86 | + | } { | |
| 87 | + | inputSend(t, server, msg) | |
| 88 | + | if err = d.Dispatch(); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | for i := 0; i < 3; i++ { | |
| 93 | + | if _, ok := m.ApplyIME(<-events); ok { | |
| 94 | + | t.Fatal("partial batch delivered") | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | batch, ok := m.ApplyIME(<-events) | |
| 98 | + | if !ok || batch.Preedit != "é" || batch.Commit != "x" || batch.DeleteBefore != 1 || m.synced { | |
| 99 | + | t.Fatalf("batch %+v ok=%v synced=%v", batch, ok, m.synced) | |
| 100 | + | } | |
| 101 | + | m.ContentType(true, false) | |
| 102 | + | if !m.pendingContent { | |
| 103 | + | t.Fatal("mismatched done changed protocol state") | |
| 104 | + | } | |
| 105 | + | m.ApplyIME(IMEEvent{Kind: "done", Serial: 1}) | |
| 106 | + | if !m.synced || m.commits <= 1 { | |
| 107 | + | t.Fatal("pending state not flushed after matching done") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestSurroundingWindowRuneBoundaries(t *testing.T) { | |
| 112 | + | s := strings.Repeat("é", 3500) | |
| 113 | + | for _, pos := range []int{0, 1, 3999, len(s) - 1, len(s)} { | |
| 114 | + | window, cursor, anchor := surroundingWindow(s, pos, len(s)) | |
| 115 | + | if len(window) > 4000 || !utf8.ValidString(window) || !utf8.ValidString(window[:cursor]) || !utf8.ValidString(window[:anchor]) { | |
| 116 | + | t.Fatalf("invalid window at %d: len=%d cursor=%d anchor=%d", pos, len(window), cursor, anchor) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestIMEPasswordContentWire(t *testing.T) { | |
| 122 | + | ca, server := inputPair(t) | |
| 123 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | defer d.Close() | |
| 128 | + | inputRequest(t, server) | |
| 129 | + | input := textinput.NewTextInputV3(d.Context()) | |
| 130 | + | input.SetID(20) | |
| 131 | + | d.Context().Register(input) | |
| 132 | + | m := &IME{input: input, entered: true, enabled: true, synced: true} | |
| 133 | + | m.ContentType(true, true) | |
| 134 | + | buf := make([]byte, 128) | |
| 135 | + | _ = server.SetReadDeadline(time.Now().Add(time.Second)) | |
| 136 | + | n, err := server.Read(buf) | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | if n < 16 || binary.LittleEndian.Uint32(buf[:4]) != input.ID() || binary.LittleEndian.Uint16(buf[4:]) != 5 { | |
| 141 | + | t.Fatalf("unexpected content request: %x", buf[:n]) | |
| 142 | + | } | |
| 143 | + | hint := binary.LittleEndian.Uint32(buf[8:12]) | |
| 144 | + | purpose := binary.LittleEndian.Uint32(buf[12:16]) | |
| 145 | + | if hint != textinput.CONTENT_HINT_SENSITIVE_DATA|textinput.CONTENT_HINT_HIDDEN_TEXT || purpose != textinput.CONTENT_PURPOSE_PASSWORD { | |
| 146 | + | t.Fatalf("password hint=%d purpose=%d", hint, purpose) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestIMEInitialStateOneCommit(t *testing.T) { | |
| 151 | + | ca, server := inputPair(t) | |
| 152 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | defer d.Close() | |
| 157 | + | inputRequest(t, server) | |
| 158 | + | input := textinput.NewTextInputV3(d.Context()) | |
| 159 | + | input.SetID(20) | |
| 160 | + | d.Context().Register(input) | |
| 161 | + | m := &IME{input: input, entered: true} | |
| 162 | + | m.Surrounding("hello", 5, 5) | |
| 163 | + | m.ContentType(true, false) | |
| 164 | + | m.CursorRect(1, 2, 3, 4) | |
| 165 | + | m.Enable() | |
| 166 | + | if m.Err != nil || m.commits != 1 { | |
| 167 | + | t.Fatalf("enable: %v commits=%d", m.Err, m.commits) | |
| 168 | + | } | |
| 169 | + | buf := make([]byte, 256) | |
| 170 | + | _ = server.SetReadDeadline(time.Now().Add(time.Second)) | |
| 171 | + | n, err := server.Read(buf) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | var ops []uint16 | |
| 176 | + | for offset := 0; offset < n; { | |
| 177 | + | size := int(binary.LittleEndian.Uint16(buf[offset+6:])) | |
| 178 | + | if size < 8 || offset+size > n { | |
| 179 | + | t.Fatalf("invalid frame: %x", buf[:n]) | |
| 180 | + | } | |
| 181 | + | ops = append(ops, binary.LittleEndian.Uint16(buf[offset+4:])) | |
| 182 | + | offset += size | |
| 183 | + | } | |
| 184 | + | if len(ops) != 5 || ops[0] != 1 || ops[1] != 5 || ops[2] != 3 || ops[3] != 6 || ops[4] != 7 { | |
| 185 | + | t.Fatalf("request order %v", ops) | |
| 186 | + | } | |
| 187 | + | } | |
A internal/platform/wayland/input.go +413 -0
| @@ -0,0 +1,413 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 11 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 12 | + | "github.com/bnema/wlturbo/wl" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // InputReset tells the runtime to synthesize pointer leave, release all buttons, | |
| 16 | + | // keyboard focus-out and repeat cancellation before processing later inputs. | |
| 17 | + | const InputReset = "reset" | |
| 18 | + | ||
| 19 | + | // Input is the platform-to-UI port. wl_surface local fixed coordinates are | |
| 20 | + | // already logical pixels and must not be divided by buffer scale. | |
| 21 | + | type Input struct { | |
| 22 | + | Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, reset | |
| 23 | + | X, Y, DX, DY float64 | |
| 24 | + | Button, Serial, Axis, AxisSource uint32 | |
| 25 | + | AxisStop bool | |
| 26 | + | Enter bool // motion from wl_pointer.enter; Serial is the enter serial | |
| 27 | + | PointerGen uint64 // wl_pointer generation that sent Enter | |
| 28 | + | Value120 int32 | |
| 29 | + | Key keyboard.Key | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // InputAdapter is owned by the session loop. Never call ApplyInput on the reader. | |
| 33 | + | // The session loop also owns the repeat timer (Tick and NextRepeat). | |
| 34 | + | type InputAdapter struct { | |
| 35 | + | Keyboard *keyboard.Keyboard | |
| 36 | + | serial uint32 | |
| 37 | + | focused bool | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // resetHeld drops held keys, repeat and compose state after InputReset while | |
| 41 | + | // keeping keyboard focus, so later key events still apply without a new enter. | |
| 42 | + | func (a *InputAdapter) resetHeld() { | |
| 43 | + | if a != nil && a.Keyboard != nil { | |
| 44 | + | a.Keyboard.Focus(a.focused) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func NewInputAdapter(locale string) (*InputAdapter, error) { | |
| 49 | + | k, err := keyboard.New(locale) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | k.Focus(false) | |
| 54 | + | return &InputAdapter{Keyboard: k}, nil | |
| 55 | + | } | |
| 56 | + | func (a *InputAdapter) Close() { | |
| 57 | + | if a != nil && a.Keyboard != nil { | |
| 58 | + | a.Keyboard.Close() | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | func (a *InputAdapter) SelectionSerial() uint32 { return a.serial } | |
| 62 | + | func (a *InputAdapter) Tick(now time.Time) (Input, bool) { | |
| 63 | + | key, ok := a.Keyboard.Tick(now) | |
| 64 | + | return Input{Kind: "key", Key: key}, ok | |
| 65 | + | } | |
| 66 | + | func (a *InputAdapter) NextRepeat() (time.Time, bool) { return a.Keyboard.NextRepeat() } | |
| 67 | + | ||
| 68 | + | // InputAdapter returns the loop-owned interpreter; the reader never calls it. | |
| 69 | + | func (w *Window) InputAdapter() *InputAdapter { return w.inputAdapter } | |
| 70 | + | ||
| 71 | + | // ApplyInput consumes the raw event including its keymap FD, on every path. | |
| 72 | + | func (a *InputAdapter) ApplyInput(ev Event, now time.Time) ([]Input, error) { | |
| 73 | + | switch ev.Kind { | |
| 74 | + | case InputFrame: | |
| 75 | + | return ev.Inputs, nil | |
| 76 | + | case InputKeymap: | |
| 77 | + | if ev.FD < 0 { | |
| 78 | + | return nil, fmt.Errorf("missing keymap fd") | |
| 79 | + | } | |
| 80 | + | if ev.Format != 1 || ev.Size == 0 || ev.Size > 16<<20 { | |
| 81 | + | CloseEventFD(ev) | |
| 82 | + | return nil, fmt.Errorf("unsupported keymap format or size") | |
| 83 | + | } | |
| 84 | + | return nil, a.Keyboard.ReplaceFD(ev.FD, int(ev.Size)) | |
| 85 | + | case InputFocusIn: | |
| 86 | + | a.Keyboard.Focus(true) | |
| 87 | + | a.focused = true | |
| 88 | + | a.serial = ev.Serial | |
| 89 | + | return []Input{{Kind: "focus-in", Serial: ev.Serial}}, nil | |
| 90 | + | case InputFocusOut: | |
| 91 | + | a.Keyboard.Focus(false) | |
| 92 | + | a.focused = false | |
| 93 | + | a.serial = 0 | |
| 94 | + | return []Input{{Kind: "focus-out"}}, nil | |
| 95 | + | case InputKey: | |
| 96 | + | if ev.Pressed && a.Keyboard != nil { | |
| 97 | + | a.serial = ev.Serial | |
| 98 | + | } | |
| 99 | + | key, err := a.Keyboard.Event(ev.Code, ev.Pressed, now) | |
| 100 | + | if err != nil || key.Physical == 0 { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | return []Input{{Kind: "key", Key: key, Serial: ev.Serial}}, nil | |
| 104 | + | case InputModifiers: | |
| 105 | + | return nil, a.Keyboard.Mask(ev.Mods[0], ev.Mods[1], ev.Mods[2], 0, 0, ev.Mods[3]) | |
| 106 | + | case InputRepeatInfo: | |
| 107 | + | a.Keyboard.RepeatInfo(int(ev.Rate), time.Duration(ev.Delay)*time.Millisecond) | |
| 108 | + | return nil, nil | |
| 109 | + | } | |
| 110 | + | return nil, fmt.Errorf("not an input event: %d", ev.Kind) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // CloseEventFD releases a keymap event not delivered to the loop, including FD 0. | |
| 114 | + | func CloseEventFD(ev Event) { | |
| 115 | + | if ev.Kind == InputKeymap && ev.FD >= 0 { | |
| 116 | + | _ = os.NewFile(uintptr(ev.FD), "keymap").Close() | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // BindSeat binds the optional initial seat. The reader posts capability changes | |
| 121 | + | // and global removal; ApplySeat runs exclusively on the session loop. | |
| 122 | + | func (w *Window) BindSeat() error { | |
| 123 | + | seat := core.NewSeat(w.Display.Context()) | |
| 124 | + | g, ok := w.Display.Registry().FindGlobal(core.SeatInterface) | |
| 125 | + | if !ok { | |
| 126 | + | w.Display.Context().Unregister(seat) | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | version, err := w.Display.Registry().BindNegotiated(core.SeatInterface, 9, seat) | |
| 130 | + | if err != nil { | |
| 131 | + | w.Display.Context().Unregister(seat) | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | w.Seat, w.seatName, w.seatVersion = seat, g.Name, version | |
| 135 | + | seat.OnCapabilities(func(c uint32) { w.post(Event{Kind: SeatCapabilities, Capabilities: c}) }) | |
| 136 | + | w.Display.Registry().AddGlobalRemoveHandler(seatRemoved{w: w, name: g.Name}) | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | type seatRemoved struct { | |
| 141 | + | w *Window | |
| 142 | + | name uint32 | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (r seatRemoved) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) { | |
| 146 | + | if e.Name == r.name { | |
| 147 | + | r.w.post(Event{Kind: SeatRemoved}) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | func (w *Window) ApplySeat(ev Event) error { | |
| 151 | + | switch ev.Kind { | |
| 152 | + | case SeatRemoved: | |
| 153 | + | if w.WLKeyboard != nil { | |
| 154 | + | w.post(Event{Kind: InputFocusOut}) | |
| 155 | + | } | |
| 156 | + | return w.releaseSeat() | |
| 157 | + | case SeatCapabilities: | |
| 158 | + | if w.Seat == nil { | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | if ev.Capabilities&1 == 0 && w.Pointer != nil { | |
| 162 | + | w.dropCursorDevice() | |
| 163 | + | w.releaseDevice(w.Pointer) | |
| 164 | + | w.Pointer = nil | |
| 165 | + | } | |
| 166 | + | if ev.Capabilities&2 == 0 && w.WLKeyboard != nil { | |
| 167 | + | w.releaseDevice(w.WLKeyboard) | |
| 168 | + | w.WLKeyboard = nil | |
| 169 | + | w.post(Event{Kind: InputFocusOut}) | |
| 170 | + | } | |
| 171 | + | if ev.Capabilities&1 != 0 && w.Pointer == nil { | |
| 172 | + | p, err := w.Seat.GetPointer() | |
| 173 | + | if err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | w.Pointer = p | |
| 177 | + | w.watchPointer(p) | |
| 178 | + | } | |
| 179 | + | if ev.Capabilities&2 != 0 && w.WLKeyboard == nil { | |
| 180 | + | k, err := w.Seat.GetKeyboard() | |
| 181 | + | if err != nil { | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | w.WLKeyboard = k | |
| 185 | + | w.watchKeyboard(k) | |
| 186 | + | } | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | return fmt.Errorf("not a seat event: %d", ev.Kind) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // releaseDevice destroys a wl_pointer or wl_keyboard. Their release request | |
| 193 | + | // exists since seat version 3; older seats only forget the proxy locally. | |
| 194 | + | func (w *Window) releaseDevice(p interface { | |
| 195 | + | wl.Proxy | |
| 196 | + | Release() error | |
| 197 | + | }) { | |
| 198 | + | if w.seatVersion >= 3 { | |
| 199 | + | _ = p.Release() | |
| 200 | + | } else if c := p.Context(); c != nil { | |
| 201 | + | c.Unregister(p) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | func (w *Window) releaseSeat() error { | |
| 205 | + | if w.Pointer != nil { | |
| 206 | + | w.dropCursorDevice() | |
| 207 | + | w.releaseDevice(w.Pointer) | |
| 208 | + | w.Pointer = nil | |
| 209 | + | } | |
| 210 | + | if w.WLKeyboard != nil { | |
| 211 | + | w.releaseDevice(w.WLKeyboard) | |
| 212 | + | w.WLKeyboard = nil | |
| 213 | + | } | |
| 214 | + | if w.Seat != nil { | |
| 215 | + | if w.seatVersion >= 5 { | |
| 216 | + | _ = w.Seat.Release() | |
| 217 | + | } else { | |
| 218 | + | w.Display.Context().Unregister(w.Seat) | |
| 219 | + | } | |
| 220 | + | w.Seat = nil | |
| 221 | + | } | |
| 222 | + | return nil | |
| 223 | + | } | |
| 224 | + | func (w *Window) watchPointer(p *core.Pointer) { | |
| 225 | + | var frame []Input // only accessed by the reader | |
| 226 | + | var x, y float64 | |
| 227 | + | var inside bool | |
| 228 | + | var source uint32 | |
| 229 | + | // The generation tells a late enter from a released pointer apart from | |
| 230 | + | // the current one; object IDs can be reused. | |
| 231 | + | w.pointerGen++ | |
| 232 | + | surfaceID, version, gen := w.Surface.ID(), w.seatVersion, w.pointerGen | |
| 233 | + | flush := func() { | |
| 234 | + | if len(frame) > 0 { | |
| 235 | + | w.post(Event{Kind: InputFrame, Inputs: frame}) | |
| 236 | + | frame = nil | |
| 237 | + | } | |
| 238 | + | source = 0 | |
| 239 | + | } | |
| 240 | + | add := func(i Input) { | |
| 241 | + | i.AxisSource = source | |
| 242 | + | frame = append(frame, i) | |
| 243 | + | if version < 5 { | |
| 244 | + | flush() | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | p.OnEnter(func(serial uint32, id uint32, px, py wl.Fixed) { | |
| 248 | + | if id != surfaceID { | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | inside = true | |
| 252 | + | x, y = px.Float64(), py.Float64() | |
| 253 | + | add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: gen}) | |
| 254 | + | }) | |
| 255 | + | p.OnLeave(func(_ uint32, id uint32) { | |
| 256 | + | if id != surfaceID { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | inside = false | |
| 260 | + | add(Input{Kind: "leave"}) | |
| 261 | + | }) | |
| 262 | + | p.OnMotion(func(_ uint32, px, py wl.Fixed) { | |
| 263 | + | if inside { | |
| 264 | + | x, y = px.Float64(), py.Float64() | |
| 265 | + | add(Input{Kind: "motion", X: x, Y: y}) | |
| 266 | + | } | |
| 267 | + | }) | |
| 268 | + | p.OnButton(func(serial, _, button, state uint32) { | |
| 269 | + | if !inside && state == 1 { | |
| 270 | + | return | |
| 271 | + | } | |
| 272 | + | kind := "release" | |
| 273 | + | if state == 1 { | |
| 274 | + | kind = "press" | |
| 275 | + | } | |
| 276 | + | add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial}) | |
| 277 | + | }) | |
| 278 | + | p.OnAxisSource(func(s uint32) { source = s }) | |
| 279 | + | p.OnAxis(func(_, axis uint32, v wl.Fixed) { | |
| 280 | + | if inside { | |
| 281 | + | i := Input{Kind: "axis", X: x, Y: y, Axis: axis} | |
| 282 | + | if axis == 0 { | |
| 283 | + | i.DY = v.Float64() | |
| 284 | + | } else { | |
| 285 | + | i.DX = v.Float64() | |
| 286 | + | } | |
| 287 | + | add(i) | |
| 288 | + | } | |
| 289 | + | }) | |
| 290 | + | p.OnAxisValue120(func(axis uint32, v int32) { | |
| 291 | + | if inside { | |
| 292 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: v}) | |
| 293 | + | } | |
| 294 | + | }) | |
| 295 | + | p.OnAxisStop(func(_, axis uint32) { | |
| 296 | + | if inside { | |
| 297 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true}) | |
| 298 | + | } | |
| 299 | + | }) | |
| 300 | + | p.OnFrame(flush) | |
| 301 | + | } | |
| 302 | + | func (w *Window) watchKeyboard(k *core.Keyboard) { | |
| 303 | + | surfaceID := w.Surface.ID() | |
| 304 | + | k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) { | |
| 305 | + | n, err := fd.Take() | |
| 306 | + | if err != nil { | |
| 307 | + | w.post(Event{Kind: TransportError, Err: err}) | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | w.postFD(Event{Kind: InputKeymap, FD: n, Format: format, Size: size}) | |
| 311 | + | }) | |
| 312 | + | k.OnEnter(func(serial, id uint32, _ []byte) { | |
| 313 | + | if id == surfaceID { | |
| 314 | + | w.post(Event{Kind: InputFocusIn, Serial: serial}) | |
| 315 | + | } | |
| 316 | + | }) | |
| 317 | + | k.OnLeave(func(_, id uint32) { | |
| 318 | + | if id == surfaceID { | |
| 319 | + | w.post(Event{Kind: InputFocusOut}) | |
| 320 | + | } | |
| 321 | + | }) | |
| 322 | + | k.OnKey(func(serial, _, key, state uint32) { | |
| 323 | + | w.post(Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1}) | |
| 324 | + | }) | |
| 325 | + | k.OnModifiers(func(_, depressed, latched, locked, group uint32) { | |
| 326 | + | w.post(Event{Kind: InputModifiers, Mods: [4]uint32{depressed, latched, locked, group}}) | |
| 327 | + | }) | |
| 328 | + | k.OnRepeatInfo(func(rate, delay int32) { w.post(Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay}) }) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // InputEvent is a loop-owned input value. Runtime integration drains these later. | |
| 332 | + | type InputEvent = Input | |
| 333 | + | ||
| 334 | + | // DrainInput appends pending input in order to dst and clears the queue. | |
| 335 | + | // The queue holds at most 4096 events. Motion is evicted first, compatible | |
| 336 | + | // adjacent axes are merged; if neither is possible it emits InputReset and | |
| 337 | + | // starts a new queue. InputOverflow counts discarded events. Call on the Apply loop. | |
| 338 | + | func (w *Window) DrainInput(dst []InputEvent) []InputEvent { | |
| 339 | + | dst = append(dst, w.inputQueue...) | |
| 340 | + | clear(w.inputQueue) | |
| 341 | + | w.inputQueue = w.inputQueue[:0] | |
| 342 | + | return dst | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func mergeAxis(dst *Input, src Input) bool { | |
| 346 | + | if dst.Kind != "axis" || src.Kind != "axis" || dst.Axis != src.Axis || | |
| 347 | + | dst.AxisSource != src.AxisSource || dst.AxisStop || src.AxisStop { | |
| 348 | + | return false | |
| 349 | + | } | |
| 350 | + | dst.DX += src.DX | |
| 351 | + | dst.DY += src.DY | |
| 352 | + | dst.Value120 += src.Value120 | |
| 353 | + | dst.X, dst.Y = src.X, src.Y | |
| 354 | + | return true | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func (w *Window) queueInput(inputs []Input) { | |
| 358 | + | for _, input := range inputs { | |
| 359 | + | if len(w.inputQueue) == 4096 { | |
| 360 | + | index := -1 | |
| 361 | + | for i, v := range w.inputQueue { | |
| 362 | + | // An enter motion carries the serial cursor shapes need. | |
| 363 | + | if v.Kind == "motion" && !v.Enter { | |
| 364 | + | index = i | |
| 365 | + | break | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | if index < 0 { | |
| 369 | + | // Adjacent axes on the same axis/source can be combined without | |
| 370 | + | // crossing a button or key event or losing scroll distance. | |
| 371 | + | for i := 0; i+1 < len(w.inputQueue); i++ { | |
| 372 | + | if mergeAxis(&w.inputQueue[i], w.inputQueue[i+1]) { | |
| 373 | + | index = i + 1 | |
| 374 | + | break | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if index >= 0 { | |
| 379 | + | if w.inputQueue[index].Kind == "motion" { | |
| 380 | + | w.InputOverflow++ | |
| 381 | + | } | |
| 382 | + | copy(w.inputQueue[index:], w.inputQueue[index+1:]) | |
| 383 | + | w.inputQueue = w.inputQueue[:len(w.inputQueue)-1] | |
| 384 | + | } else { | |
| 385 | + | // A previous reset is still pending if the consumer has not | |
| 386 | + | // drained yet; keep one marker across successive overflows. | |
| 387 | + | dropped := len(w.inputQueue) | |
| 388 | + | if w.inputQueue[0].Kind == InputReset { | |
| 389 | + | dropped-- | |
| 390 | + | } | |
| 391 | + | w.InputOverflow += uint64(dropped) | |
| 392 | + | // Keep the latest discarded enter or leave: it still decides | |
| 393 | + | // whether the pointer is inside, and an enter carries the serial | |
| 394 | + | // cursor shapes need. | |
| 395 | + | var focus *Input | |
| 396 | + | for i := range w.inputQueue { | |
| 397 | + | if v := w.inputQueue[i]; v.Enter || v.Kind == "leave" { | |
| 398 | + | focus = &v | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | clear(w.inputQueue) | |
| 402 | + | w.inputQueue = w.inputQueue[:0] | |
| 403 | + | w.inputQueue = append(w.inputQueue, Input{Kind: InputReset}) | |
| 404 | + | if focus != nil { | |
| 405 | + | w.inputQueue = append(w.inputQueue, *focus) | |
| 406 | + | } | |
| 407 | + | // Discarded presses must not keep repeating on the loop. | |
| 408 | + | w.inputAdapter.resetHeld() | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | w.inputQueue = append(w.inputQueue, input) | |
| 412 | + | } | |
| 413 | + | } | |
A internal/platform/wayland/input.md +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | # Wayland input boundary | |
| 2 | + | ||
| 3 | + | `Connect` binds the optional initial `wl_seat`. `StartReader` owns all Wayland | |
| 4 | + | callbacks and posts typed `Event` values. The session loop must call `ApplySeat` | |
| 5 | + | for capability/removal events, `InputAdapter.ApplyInput` for input events, and | |
| 6 | + | translate resulting `Input` values to the root `platformInput` later, at the | |
| 7 | + | runtime wiring boundary. Callbacks never run the editor, keyboard interpreter, | |
| 8 | + | or UI. On shutdown, `Window.Close` closes unconsumed keymap FDs in its queues. | |
| 9 | + | A consumer that drops an event before dispatch must use `CloseEventFD`. | |
| 10 | + | ||
| 11 | + | `wl_pointer` surface-local fixed-point coordinates are **already logical**; | |
| 12 | + | do not apply window buffer scale. A version 5+ pointer frame becomes one ordered | |
| 13 | + | `InputFrame`. For older pointers events flush individually. Axis deltas preserve | |
| 14 | + | their native Wayland units; `value120`, stop, and source metadata are additional | |
| 15 | + | axis events, not extra pixel scroll deltas. Consumers must avoid counting both | |
| 16 | + | axis and value120 for the same frame. A button release outside the surface is | |
| 17 | + | still delivered, for cancellation of pointer capture. | |
| 18 | + | ||
| 19 | + | The bounded input queue evicts old motion first, then coalesces adjacent axis | |
| 20 | + | values on the same axis/source (without crossing discrete events). If still full, | |
| 21 | + | it discards the queued state and emits one `InputReset` (`Kind == "reset"`) | |
| 22 | + | before later events. The runtime must treat reset as pointer leave + release of | |
| 23 | + | all buttons + keyboard focus-out/cancel repeat, then re-sync from later events. | |
| 24 | + | The adapter itself drops held keys and repeat on reset but keeps compositor | |
| 25 | + | keyboard focus, so later key events apply without a new enter. | |
| 26 | + | `InputOverflow` counts discarded inputs; merging lossless axes does not count. | |
| 27 | + | ||
| 28 | + | A keymap event transfers its FD via `OwnedFD.Take()` from the reader to exactly | |
| 29 | + | one loop event; `ApplyInput` passes it to `keyboard.ReplaceFD`, which closes it | |
| 30 | + | on success or error. Repeat info and focus loss are applied only on the loop. | |
| 31 | + | The compositor serial from keyboard enter or key press is exposed for selection | |
| 32 | + | ownership. `Tick` and `NextRepeat` belong to that same loop, never the reader. | |
A internal/platform/wayland/input_test.go +359 -0
| @@ -0,0 +1,359 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "net" | |
| 8 | + | "os" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/wlturbo" | |
| 13 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func inputPair(t *testing.T) (*net.UnixConn, *net.UnixConn) { | |
| 18 | + | t.Helper() | |
| 19 | + | f, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | a := os.NewFile(uintptr(f[0]), "client") | |
| 24 | + | b := os.NewFile(uintptr(f[1]), "server") | |
| 25 | + | ca, e := net.FileConn(a) | |
| 26 | + | _ = a.Close() | |
| 27 | + | if e != nil { | |
| 28 | + | t.Fatal(e) | |
| 29 | + | } | |
| 30 | + | cb, e := net.FileConn(b) | |
| 31 | + | _ = b.Close() | |
| 32 | + | if e != nil { | |
| 33 | + | t.Fatal(e) | |
| 34 | + | } | |
| 35 | + | t.Cleanup(func() { _ = ca.Close(); _ = cb.Close() }) | |
| 36 | + | return ca.(*net.UnixConn), cb.(*net.UnixConn) | |
| 37 | + | } | |
| 38 | + | func inputFrame(id uint32, op uint16, body ...uint32) []byte { | |
| 39 | + | b := make([]byte, 8+len(body)*4) | |
| 40 | + | binary.LittleEndian.PutUint32(b, id) | |
| 41 | + | binary.LittleEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(op)) | |
| 42 | + | for i, v := range body { | |
| 43 | + | binary.LittleEndian.PutUint32(b[8+i*4:], v) | |
| 44 | + | } | |
| 45 | + | return b | |
| 46 | + | } | |
| 47 | + | func inputSend(t *testing.T, p *net.UnixConn, msg []byte, fd ...int) { | |
| 48 | + | t.Helper() | |
| 49 | + | var oob []byte | |
| 50 | + | if len(fd) > 0 { | |
| 51 | + | oob = unix.UnixRights(fd...) | |
| 52 | + | } | |
| 53 | + | if _, _, err := p.WriteMsgUnix(msg, oob, nil); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | func inputRequest(t *testing.T, p *net.UnixConn) { | |
| 58 | + | t.Helper() | |
| 59 | + | _ = p.SetReadDeadline(time.Now().Add(time.Second)) | |
| 60 | + | b := make([]byte, 256) | |
| 61 | + | if _, err := p.Read(b); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | func TestPointerFrameOverSocketpair(t *testing.T) { | |
| 66 | + | ca, server := inputPair(t) | |
| 67 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | defer d.Close() | |
| 72 | + | inputRequest(t, server) | |
| 73 | + | ctx := d.Context() | |
| 74 | + | seat := core.NewSeat(ctx) | |
| 75 | + | if err = d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | inputRequest(t, server) | |
| 79 | + | p, err := seat.GetPointer() | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | inputRequest(t, server) | |
| 84 | + | surface := core.NewSurface(ctx) | |
| 85 | + | surface.SetID(30) | |
| 86 | + | ctx.Register(surface) | |
| 87 | + | w := &Window{Surface: surface, seatVersion: 9, events: make(chan Event, 10), readerStop: make(chan struct{})} | |
| 88 | + | w.watchPointer(p) | |
| 89 | + | for _, msg := range [][]byte{ | |
| 90 | + | inputFrame(p.ID(), 0, 1, 30, 256*12, 256*7), // enter: surface-local logical pixels | |
| 91 | + | inputFrame(p.ID(), 2, 2, 256*14, 256*8), | |
| 92 | + | inputFrame(p.ID(), 6, 1), // wheel source | |
| 93 | + | inputFrame(p.ID(), 4, 3, 0, 256*5), | |
| 94 | + | inputFrame(p.ID(), 9, 0, 120), | |
| 95 | + | inputFrame(p.ID(), 3, 4, 4, 0x110, 1), | |
| 96 | + | inputFrame(p.ID(), 5), | |
| 97 | + | } { | |
| 98 | + | inputSend(t, server, msg) | |
| 99 | + | if err = d.Dispatch(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | select { | |
| 104 | + | case ev := <-w.events: | |
| 105 | + | if ev.Kind != InputFrame || len(ev.Inputs) != 5 { | |
| 106 | + | t.Fatalf("frame: %+v", ev) | |
| 107 | + | } | |
| 108 | + | if !ev.Inputs[0].Enter || ev.Inputs[0].Serial != 1 || ev.Inputs[0].PointerGen != w.pointerGen || ev.Inputs[0].X != 12 || ev.Inputs[1].X != 14 || ev.Inputs[2].DY != 5 || ev.Inputs[2].AxisSource != 1 || ev.Inputs[3].Value120 != 120 || ev.Inputs[4].Kind != "press" { | |
| 109 | + | t.Fatalf("inputs: %+v", ev.Inputs) | |
| 110 | + | } | |
| 111 | + | default: | |
| 112 | + | t.Fatal("missing pointer frame") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | func TestKeyboardFDAndRepeatOverSocketpair(t *testing.T) { | |
| 116 | + | ca, server := inputPair(t) | |
| 117 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | defer d.Close() | |
| 122 | + | inputRequest(t, server) | |
| 123 | + | ctx := d.Context() | |
| 124 | + | seat := core.NewSeat(ctx) | |
| 125 | + | if err = d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | inputRequest(t, server) | |
| 129 | + | k, err := seat.GetKeyboard() | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | inputRequest(t, server) | |
| 134 | + | surface := core.NewSurface(ctx) | |
| 135 | + | surface.SetID(30) | |
| 136 | + | ctx.Register(surface) | |
| 137 | + | w := &Window{Surface: surface, events: make(chan Event, 10), readerStop: make(chan struct{})} | |
| 138 | + | w.watchKeyboard(k) | |
| 139 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 140 | + | if err != nil { | |
| 141 | + | t.Fatal(err) | |
| 142 | + | } | |
| 143 | + | defer f.Close() | |
| 144 | + | inputSend(t, server, inputFrame(k.ID(), 0, 0, 5), int(f.Fd())) | |
| 145 | + | if err = d.Dispatch(); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | ev := <-w.events | |
| 149 | + | if ev.Kind != InputKeymap || ev.FD < 0 { | |
| 150 | + | t.Fatalf("keymap: %+v", ev) | |
| 151 | + | } | |
| 152 | + | adapter, err := NewInputAdapter("") | |
| 153 | + | if err != nil { | |
| 154 | + | t.Skipf("xkb unavailable: %v", err) | |
| 155 | + | } | |
| 156 | + | defer adapter.Close() | |
| 157 | + | if _, err = adapter.ApplyInput(ev, time.Now()); err == nil { | |
| 158 | + | t.Fatal("unsupported keymap accepted") | |
| 159 | + | } | |
| 160 | + | if err = unix.Fstat(ev.FD, &unix.Stat_t{}); err != unix.EBADF { | |
| 161 | + | t.Fatalf("fd not closed: %v", err) | |
| 162 | + | } | |
| 163 | + | inputSend(t, server, inputFrame(k.ID(), 5, 0, 200)) | |
| 164 | + | if err = d.Dispatch(); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | if _, err = adapter.ApplyInput(<-w.events, time.Now()); err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | if _, ok := adapter.NextRepeat(); ok { | |
| 171 | + | t.Fatal("repeat should be disabled") | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Each keymap path transfers ownership to the adapter or the discarded-event | |
| 176 | + | // cleanup, including a valid XKB keymap and malformed compositor payloads. | |
| 177 | + | func TestKeymapOwnershipAndFocusRepeat(t *testing.T) { | |
| 178 | + | adapter, err := NewInputAdapter("") | |
| 179 | + | if err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | defer adapter.Close() | |
| 183 | + | data, err := os.ReadFile("../../keyboard/testdata/fr.xkb") | |
| 184 | + | if err != nil { | |
| 185 | + | t.Fatal(err) | |
| 186 | + | } | |
| 187 | + | for _, tc := range []struct { | |
| 188 | + | name string | |
| 189 | + | data []byte | |
| 190 | + | format uint32 | |
| 191 | + | discard bool | |
| 192 | + | good bool | |
| 193 | + | }{ | |
| 194 | + | {"valid", append(data, 0), 1, false, true}, | |
| 195 | + | {"invalid", []byte("bad keymap\x00"), 1, false, false}, | |
| 196 | + | {"unsupported", append(data, 0), 2, false, false}, | |
| 197 | + | {"disconnect", append(data, 0), 1, true, false}, | |
| 198 | + | } { | |
| 199 | + | t.Run(tc.name, func(t *testing.T) { | |
| 200 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 201 | + | if err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | if _, err = f.Write(tc.data); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | // ApplyInput owns a duplicate, not the os.File descriptor. | |
| 208 | + | fd, err := unix.Dup(int(f.Fd())) | |
| 209 | + | if err != nil { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | defer f.Close() | |
| 213 | + | ev := Event{Kind: InputKeymap, FD: fd, Format: tc.format, Size: uint32(len(tc.data))} | |
| 214 | + | if tc.discard { | |
| 215 | + | CloseEventFD(ev) | |
| 216 | + | } else { | |
| 217 | + | _, err = adapter.ApplyInput(ev, time.Now()) | |
| 218 | + | if (err == nil) != tc.good { | |
| 219 | + | t.Fatalf("apply: %v", err) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | if err = unix.Fstat(ev.FD, &unix.Stat_t{}); err != unix.EBADF { | |
| 223 | + | t.Fatalf("keymap FD still open: %v", err) | |
| 224 | + | } | |
| 225 | + | }) | |
| 226 | + | } | |
| 227 | + | now := time.Now() | |
| 228 | + | for _, ev := range []Event{{Kind: InputFocusIn, Serial: 42}, {Kind: InputRepeatInfo, Rate: 20, Delay: 100}, {Kind: InputKey, Code: 16, Pressed: true, Serial: 43}} { | |
| 229 | + | if _, err := adapter.ApplyInput(ev, now); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | if _, ok := adapter.NextRepeat(); !ok { | |
| 234 | + | t.Fatal("missing repeat") | |
| 235 | + | } | |
| 236 | + | if _, err := adapter.ApplyInput(Event{Kind: InputRepeatInfo, Rate: 0}, now); err != nil { | |
| 237 | + | t.Fatal(err) | |
| 238 | + | } | |
| 239 | + | if _, ok := adapter.NextRepeat(); ok { | |
| 240 | + | t.Fatal("rate zero retained repeat") | |
| 241 | + | } | |
| 242 | + | if _, err := adapter.ApplyInput(Event{Kind: InputRepeatInfo, Rate: 20, Delay: 100}, now); err != nil { | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | if _, err := adapter.ApplyInput(Event{Kind: InputKey, Code: 16, Pressed: true}, now); err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | if _, ok := adapter.NextRepeat(); !ok { | |
| 249 | + | t.Fatal("repeat not rearmed") | |
| 250 | + | } | |
| 251 | + | if _, err := adapter.ApplyInput(Event{Kind: InputFocusOut}, now); err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | if _, ok := adapter.NextRepeat(); ok { | |
| 255 | + | t.Fatal("keyboard leave retained repeat") | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // Overflow reset cancels repeat from a discarded press but keeps focus. | |
| 259 | + | w := &Window{inputAdapter: adapter} | |
| 260 | + | for _, ev := range []Event{{Kind: InputFocusIn, Serial: 44}, {Kind: InputKey, Code: 16, Pressed: true, Serial: 45}} { | |
| 261 | + | if _, err := adapter.ApplyInput(ev, now); err != nil { | |
| 262 | + | t.Fatal(err) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | for i := 0; i <= 4096; i++ { | |
| 266 | + | w.queueInput([]Input{{Kind: "press"}}) | |
| 267 | + | } | |
| 268 | + | if _, ok := adapter.NextRepeat(); ok { | |
| 269 | + | t.Fatal("overflow reset retained repeat") | |
| 270 | + | } | |
| 271 | + | out, err := adapter.ApplyInput(Event{Kind: InputKey, Code: 16, Pressed: true, Serial: 46}, now) | |
| 272 | + | if err != nil || len(out) != 1 { | |
| 273 | + | t.Fatalf("key after reset: %v %+v", err, out) | |
| 274 | + | } | |
| 275 | + | if _, ok := adapter.NextRepeat(); !ok { | |
| 276 | + | t.Fatal("focus lost by overflow reset") | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func TestInputQueueOverflowResetAndCoalescing(t *testing.T) { | |
| 281 | + | w := &Window{} | |
| 282 | + | for i := 0; i < 4096; i++ { | |
| 283 | + | w.queueInput([]Input{{Kind: "press", Button: uint32(i)}}) | |
| 284 | + | } | |
| 285 | + | w.queueInput([]Input{{Kind: "release", Button: 7}}) | |
| 286 | + | got := w.DrainInput(nil) | |
| 287 | + | if len(got) != 2 || got[0].Kind != InputReset || got[1].Kind != "release" || w.InputOverflow != 4096 { | |
| 288 | + | t.Fatalf("reset: len=%d events=%+v overflows=%d", len(got), got, w.InputOverflow) | |
| 289 | + | } | |
| 290 | + | w.queueInput([]Input{{Kind: "motion", X: 1}}) | |
| 291 | + | for i := 1; i < 4096; i++ { | |
| 292 | + | w.queueInput([]Input{{Kind: "press"}}) | |
| 293 | + | } | |
| 294 | + | w.queueInput([]Input{{Kind: "focus-out"}}) | |
| 295 | + | got = w.DrainInput(nil) | |
| 296 | + | if len(got) != 4096 || got[0].Kind != "press" || got[4095].Kind != "focus-out" || w.InputOverflow != 4097 { | |
| 297 | + | t.Fatalf("eviction: len=%d first=%+v last=%+v overflows=%d", len(got), got[0], got[len(got)-1], w.InputOverflow) | |
| 298 | + | } | |
| 299 | + | w.queueInput([]Input{{Kind: "axis", Axis: 1, DY: 2}}) | |
| 300 | + | w.queueInput([]Input{{Kind: "axis", Axis: 1, DY: 3}}) | |
| 301 | + | for i := 2; i < 4096; i++ { | |
| 302 | + | w.queueInput([]Input{{Kind: "press"}}) | |
| 303 | + | } | |
| 304 | + | w.queueInput([]Input{{Kind: "release"}}) | |
| 305 | + | got = w.DrainInput(nil) | |
| 306 | + | if len(got) != 4096 || got[0].Kind != "axis" || got[0].DY != 5 || got[4095].Kind != "release" || w.InputOverflow != 4097 { | |
| 307 | + | t.Fatalf("axis coalescing: len=%d first=%+v last=%+v overflows=%d", len(got), got[0], got[len(got)-1], w.InputOverflow) | |
| 308 | + | } | |
| 309 | + | // Eviction skips enter motions; a reset keeps the latest one. | |
| 310 | + | w.queueInput([]Input{{Kind: "motion", Enter: true, Serial: 5}}) | |
| 311 | + | for i := 1; i < 4096; i++ { | |
| 312 | + | w.queueInput([]Input{{Kind: "press"}}) | |
| 313 | + | } | |
| 314 | + | w.queueInput([]Input{{Kind: "release"}}) | |
| 315 | + | got = w.DrainInput(nil) | |
| 316 | + | if len(got) != 3 || got[0].Kind != InputReset || !got[1].Enter || got[1].Serial != 5 || got[2].Kind != "release" { | |
| 317 | + | t.Fatalf("enter across reset: len=%d first=%+v", len(got), got[:min(3, len(got))]) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestApplySeatPointerKeyAndDrain(t *testing.T) { | |
| 322 | + | w := &Window{} | |
| 323 | + | data, err := os.ReadFile("../../keyboard/testdata/fr.xkb") | |
| 324 | + | if err != nil { | |
| 325 | + | t.Fatal(err) | |
| 326 | + | } | |
| 327 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 328 | + | if err != nil { | |
| 329 | + | t.Fatal(err) | |
| 330 | + | } | |
| 331 | + | if _, err = f.Write(append(data, 0)); err != nil { | |
| 332 | + | t.Fatal(err) | |
| 333 | + | } | |
| 334 | + | // ApplyInput consumes the event FD; keep the os.File descriptor separate. | |
| 335 | + | fd, err := unix.Dup(int(f.Fd())) | |
| 336 | + | if err != nil { | |
| 337 | + | t.Fatal(err) | |
| 338 | + | } | |
| 339 | + | defer f.Close() | |
| 340 | + | for _, ev := range []Event{ | |
| 341 | + | {Kind: InputKeymap, FD: fd, Format: 1, Size: uint32(len(data) + 1)}, | |
| 342 | + | {Kind: InputFocusIn, Serial: 3}, | |
| 343 | + | {Kind: SeatCapabilities, Capabilities: 3}, | |
| 344 | + | {Kind: InputFrame, Inputs: []Input{{Kind: "motion", X: 4}, {Kind: "press", Button: 1}}}, | |
| 345 | + | {Kind: InputKey, Code: 16, Pressed: true, Serial: 4}, | |
| 346 | + | } { | |
| 347 | + | if err := w.Apply(ev); err != nil { | |
| 348 | + | t.Fatal(err) | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | defer w.inputAdapter.Close() | |
| 352 | + | got := w.DrainInput(nil) | |
| 353 | + | if len(got) != 4 || got[0].Kind != "focus-in" || got[1].Kind != "motion" || got[2].Kind != "press" || got[3].Kind != "key" { | |
| 354 | + | t.Fatalf("drained: %+v", got) | |
| 355 | + | } | |
| 356 | + | if len(w.DrainInput(nil)) != 0 { | |
| 357 | + | t.Fatal("queue not drained") | |
| 358 | + | } | |
| 359 | + | } | |
A internal/platform/wayland/sequence_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefergui/internal/presentation/buffers" | |
| 13 | + | "github.com/bnema/wlturbo" | |
| 14 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 15 | + | "github.com/bnema/wlturbo/protocol/drmsyncobj" | |
| 16 | + | "github.com/bnema/wlturbo/wl" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // A socket peer records real wire requests (not a mocked Present implementation). | |
| 20 | + | // Every frame must carry both sync points and a buffer on the same commit. | |
| 21 | + | func TestPresentWireSequence(t *testing.T) { | |
| 22 | + | client, server := net.Pipe() | |
| 23 | + | defer server.Close() | |
| 24 | + | // ConnectFromConn sends get_registry before it returns: drain the pipe first. | |
| 25 | + | type request struct { | |
| 26 | + | id uint32 | |
| 27 | + | opcode uint16 | |
| 28 | + | args []byte | |
| 29 | + | } | |
| 30 | + | requests := make(chan request, 64) | |
| 31 | + | errs := make(chan error, 1) | |
| 32 | + | go func() { | |
| 33 | + | for { | |
| 34 | + | header := make([]byte, 8) | |
| 35 | + | if _, e := io.ReadFull(server, header); e != nil { | |
| 36 | + | errs <- e | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | n := int(binary.LittleEndian.Uint32(header[4:]) >> 16) | |
| 40 | + | data := make([]byte, n-8) | |
| 41 | + | if _, e := io.ReadFull(server, data); e != nil { | |
| 42 | + | errs <- e | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | requests <- request{binary.LittleEndian.Uint32(header), binary.LittleEndian.Uint16(header[4:]), data} | |
| 46 | + | } | |
| 47 | + | }() | |
| 48 | + | d, err := wlturbo.ConnectFromConn(client) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | defer d.Close() | |
| 53 | + | ctx := d.Context() | |
| 54 | + | surface := core.NewSurface(ctx) | |
| 55 | + | syncSurface := drmsyncobj.NewWpLinuxDrmSyncobjSurface(ctx) | |
| 56 | + | acquire := drmsyncobj.NewWpLinuxDrmSyncobjTimeline(ctx) | |
| 57 | + | release := drmsyncobj.NewWpLinuxDrmSyncobjTimeline(ctx) | |
| 58 | + | buffer := core.NewBuffer(ctx) | |
| 59 | + | for i, p := range []wl.Proxy{surface, syncSurface, acquire, release, buffer} { | |
| 60 | + | p.SetID(uint32(20 + i)) | |
| 61 | + | ctx.Register(p) | |
| 62 | + | } | |
| 63 | + | w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FrameReady: true, Transparent: true} | |
| 64 | + | p, _ := buffers.New(1) | |
| 65 | + | // The fake server receives a finite number of requests; no dispatch loop/GPU. | |
| 66 | + | for frame := 0; frame < 2; frame++ { | |
| 67 | + | b, e := p.Begin() | |
| 68 | + | if e != nil { | |
| 69 | + | t.Fatal(e) | |
| 70 | + | } | |
| 71 | + | a, e := p.Acquire(b) | |
| 72 | + | if e != nil { | |
| 73 | + | t.Fatal(e) | |
| 74 | + | } | |
| 75 | + | z, e := p.Commit(b) | |
| 76 | + | if e != nil { | |
| 77 | + | t.Fatal(e) | |
| 78 | + | } | |
| 79 | + | if e = w.Present(buffer, acquire, release, a, z); e != nil { | |
| 80 | + | t.Fatal(e) | |
| 81 | + | } | |
| 82 | + | if e = p.Own(b); e != nil { | |
| 83 | + | t.Fatal(e) | |
| 84 | + | } | |
| 85 | + | // Observe the generated protocol sequence up to commit. Ignore ancillary | |
| 86 | + | // viewport/opaque/damage/frame requests, but ensure they do not replace the buffer. | |
| 87 | + | var events []string | |
| 88 | + | var attached, acquired, released bool | |
| 89 | + | deadline := time.After(3 * time.Second) | |
| 90 | + | loop: | |
| 91 | + | for { | |
| 92 | + | select { | |
| 93 | + | case q := <-requests: | |
| 94 | + | switch { | |
| 95 | + | case q.id == syncSurface.ID() && q.opcode == 1: | |
| 96 | + | acquired = true | |
| 97 | + | events = append(events, "acquire") | |
| 98 | + | case q.id == syncSurface.ID() && q.opcode == 2: | |
| 99 | + | released = true | |
| 100 | + | events = append(events, "release") | |
| 101 | + | case q.id == surface.ID() && q.opcode == 1: | |
| 102 | + | attached = binary.LittleEndian.Uint32(q.args) == buffer.ID() | |
| 103 | + | events = append(events, "attach") | |
| 104 | + | case q.id == surface.ID() && q.opcode == 6: | |
| 105 | + | events = append(events, "commit") | |
| 106 | + | break loop | |
| 107 | + | } | |
| 108 | + | case e := <-errs: | |
| 109 | + | t.Fatal(e) | |
| 110 | + | case <-deadline: | |
| 111 | + | t.Fatal("missing commit") | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | if !attached || !acquired || !released || len(events) != 4 || events[0] != "acquire" || events[1] != "release" || events[2] != "attach" || events[3] != "commit" { | |
| 115 | + | t.Fatalf("frame %d wire sequence %v attached=%v", frame, events, attached) | |
| 116 | + | } | |
| 117 | + | if e = p.Release(b); e != nil { | |
| 118 | + | t.Fatal(e) | |
| 119 | + | } | |
| 120 | + | if _, e = p.Begin(); e != buffers.ErrNoBuffer { | |
| 121 | + | t.Fatalf("wl_buffer.release reused buffer: %v", e) | |
| 122 | + | } | |
| 123 | + | if e = p.Signal(b, z); e != nil { | |
| 124 | + | t.Fatal(e) | |
| 125 | + | } | |
| 126 | + | if e = p.Reuse(b, true); e != nil { | |
| 127 | + | t.Fatal(e) | |
| 128 | + | } | |
| 129 | + | w.FrameReady = true // synthetic frame callback | |
| 130 | + | } | |
| 131 | + | } | |
A internal/platform/wayland/size_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/wlturbo/protocol/viewporter" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestPhysicalSizeCeilsFractionalScale(t *testing.T) { | |
| 13 | + | for _, tc := range []struct { | |
| 14 | + | width, height int32 | |
| 15 | + | scale float64 | |
| 16 | + | wantW, wantH int32 | |
| 17 | + | }{ | |
| 18 | + | {3, 5, 1.25, 4, 7}, {3, 5, 1.5, 5, 8}, {4, 6, 1.5, 6, 9}, {3, 5, 1.25 + 1e-12, 4, 7}, | |
| 19 | + | } { | |
| 20 | + | w := &Window{Width: tc.width, Height: tc.height, Scale: tc.scale, Viewport: &viewporter.WpViewport{}} | |
| 21 | + | x, y, err := w.PhysicalSize() | |
| 22 | + | if err != nil || x != tc.wantW || y != tc.wantH { | |
| 23 | + | t.Fatalf("%dx%d scale %g: %dx%d err=%v; want %dx%d", tc.width, tc.height, tc.scale, x, y, err, tc.wantW, tc.wantH) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestFeedbackAppliedOnLoop(t *testing.T) { | |
| 29 | + | w := &Window{events: make(chan Event, 2), readerStop: make(chan struct{})} | |
| 30 | + | w.readerStarted.Store(true) | |
| 31 | + | go w.feedbackFailure(fmt.Errorf("feedback failed")) | |
| 32 | + | ev := <-w.events | |
| 33 | + | if w.feedbackErr != nil { | |
| 34 | + | t.Fatal("reader mutated loop-owned error") | |
| 35 | + | } | |
| 36 | + | if err := w.Apply(ev); err == nil || w.feedbackErr == nil { | |
| 37 | + | t.Fatalf("apply: %v feedback: %v", err, w.feedbackErr) | |
| 38 | + | } | |
| 39 | + | } | |
A internal/platform/wayland/window.go +523 -0
| @@ -0,0 +1,523 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | // Package wayland owns the minimal xdg-shell window and presentation protocol | |
| 4 | + | // objects. Vulkan allocations and DMA-BUF image creation remain the renderer's | |
| 5 | + | // responsibility; a window may not attach until the first configure is acked. | |
| 6 | + | package wayland | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/binary" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "math" | |
| 13 | + | "sync" | |
| 14 | + | "sync/atomic" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/wlturbo" | |
| 17 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 18 | + | "github.com/bnema/wlturbo/protocol/cursorshape" | |
| 19 | + | "github.com/bnema/wlturbo/protocol/drmsyncobj" | |
| 20 | + | "github.com/bnema/wlturbo/protocol/fractionalscale" | |
| 21 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 22 | + | "github.com/bnema/wlturbo/protocol/viewporter" | |
| 23 | + | "github.com/bnema/wlturbo/protocol/xdgshell" | |
| 24 | + | "github.com/bnema/wlturbo/wl" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // CapabilityError reports a missing or insufficient required protocol. | |
| 28 | + | type CapabilityError struct { | |
| 29 | + | Name string | |
| 30 | + | Cause error | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (e *CapabilityError) Error() string { | |
| 34 | + | return fmt.Sprintf("Wayland capability %s: %v", e.Name, e.Cause) | |
| 35 | + | } | |
| 36 | + | func (e *CapabilityError) Unwrap() error { return e.Cause } | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | DRMXRGB8888 = uint32(0x34325258) | |
| 40 | + | DRMARGB8888 = uint32(0x34325241) | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | type Window struct { | |
| 44 | + | Display *wl.Display | |
| 45 | + | Compositor *core.Compositor | |
| 46 | + | Shell *xdgshell.XdgWmBase | |
| 47 | + | Dmabuf *linuxdmabuf.LinuxDmabuf | |
| 48 | + | SyncManager *drmsyncobj.WpLinuxDrmSyncobjManager | |
| 49 | + | Surface *core.Surface | |
| 50 | + | XdgSurface *xdgshell.XdgSurface | |
| 51 | + | Toplevel *xdgshell.XdgToplevel | |
| 52 | + | SyncSurface *drmsyncobj.WpLinuxDrmSyncobjSurface | |
| 53 | + | Viewporter *viewporter.WpViewporter | |
| 54 | + | Viewport *viewporter.WpViewport | |
| 55 | + | ScaleManager *fractionalscale.WpFractionalScaleManager | |
| 56 | + | Fractional *fractionalscale.WpFractionalScale | |
| 57 | + | Feedback *linuxdmabuf.LinuxDmabufFeedback | |
| 58 | + | Seat *core.Seat | |
| 59 | + | Pointer *core.Pointer | |
| 60 | + | CursorShapes *cursorshape.WpCursorShapeManager // optional | |
| 61 | + | cursor cursorState | |
| 62 | + | pointerGen uint64 // bumped per wl_pointer; session loop only | |
| 63 | + | WLKeyboard *core.Keyboard | |
| 64 | + | seatName, seatVersion uint32 | |
| 65 | + | Width, Height int32 | |
| 66 | + | Scale float64 | |
| 67 | + | Configured, Closed, Dirty bool | |
| 68 | + | Transparent bool | |
| 69 | + | FrameReady bool | |
| 70 | + | // Feedback fields are populated by the default feedback event stream. | |
| 71 | + | MainDevice uint64 | |
| 72 | + | Formats []linuxdmabuf.FormatEntry | |
| 73 | + | Tranches [][]linuxdmabuf.FormatEntry | |
| 74 | + | feedbackErr error | |
| 75 | + | events chan Event | |
| 76 | + | readerDone chan struct{} | |
| 77 | + | readerStop chan struct{} | |
| 78 | + | readerOnce sync.Once | |
| 79 | + | readerStarted atomic.Bool | |
| 80 | + | inputAdapter *InputAdapter | |
| 81 | + | inputQueue []InputEvent | |
| 82 | + | // InputOverflow counts events discarded when the 4096-event queue fills. | |
| 83 | + | InputOverflow uint64 | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func Connect(name string, width, height int32, transparent bool) (_ *Window, err error) { | |
| 87 | + | if width <= 0 || height <= 0 { | |
| 88 | + | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) | |
| 89 | + | } | |
| 90 | + | d, err := wl.Connect(name) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} | |
| 95 | + | defer func() { | |
| 96 | + | if err != nil { | |
| 97 | + | _ = w.Close() | |
| 98 | + | } | |
| 99 | + | }() | |
| 100 | + | if err = d.Roundtrip(); err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | ctx := d.Context() | |
| 104 | + | bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error { | |
| 105 | + | advertised, ok := d.Registry().FindGlobal(name) | |
| 106 | + | if !ok || advertised.Version < minimum { | |
| 107 | + | return &CapabilityError{Name: name, Cause: fmt.Errorf("requires version %d, announced %d", minimum, advertised.Version)} | |
| 108 | + | } | |
| 109 | + | got, e := d.Registry().BindNegotiated(name, supported, proxy) | |
| 110 | + | if e != nil { | |
| 111 | + | return &CapabilityError{Name: name, Cause: e} | |
| 112 | + | } | |
| 113 | + | if got < minimum { | |
| 114 | + | return &CapabilityError{Name: name, Cause: fmt.Errorf("requires version %d, compositor negotiated %d", minimum, got)} | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | w.Compositor = core.NewCompositor(ctx) | |
| 119 | + | if err = bind(core.CompositorInterface, 6, 1, w.Compositor); err != nil { | |
| 120 | + | return nil, err | |
| 121 | + | } | |
| 122 | + | w.Shell = xdgshell.NewXdgWmBase(ctx) | |
| 123 | + | if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | w.Dmabuf = linuxdmabuf.NewLinuxDmabuf(ctx) | |
| 127 | + | if err = bind(linuxdmabuf.LinuxDmabufInterface, 4, 4, w.Dmabuf); err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | w.SyncManager = drmsyncobj.NewWpLinuxDrmSyncobjManager(ctx) | |
| 131 | + | if err = bind(drmsyncobj.WpLinuxDrmSyncobjManagerInterface, 1, 1, w.SyncManager); err != nil { | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | w.Viewporter = viewporter.NewWpViewporter(ctx) | |
| 135 | + | if _, e := d.Registry().BindNegotiated(viewporter.WpViewporterInterface, 1, w.Viewporter); e != nil { | |
| 136 | + | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 137 | + | return nil, e | |
| 138 | + | } | |
| 139 | + | ctx.Unregister(w.Viewporter) | |
| 140 | + | w.Viewporter = nil | |
| 141 | + | } | |
| 142 | + | w.ScaleManager = fractionalscale.NewWpFractionalScaleManager(ctx) | |
| 143 | + | if _, e := d.Registry().BindNegotiated(fractionalscale.WpFractionalScaleManagerInterface, 1, w.ScaleManager); e != nil { | |
| 144 | + | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 145 | + | return nil, e | |
| 146 | + | } | |
| 147 | + | ctx.Unregister(w.ScaleManager) | |
| 148 | + | w.ScaleManager = nil | |
| 149 | + | } | |
| 150 | + | w.CursorShapes = cursorshape.NewWpCursorShapeManager(ctx) | |
| 151 | + | if _, e := d.Registry().BindNegotiated(cursorshape.WpCursorShapeManagerInterface, 1, w.CursorShapes); e != nil { | |
| 152 | + | if !errors.Is(e, wlturbo.ErrGlobalNotFound) { | |
| 153 | + | return nil, e | |
| 154 | + | } | |
| 155 | + | ctx.Unregister(w.CursorShapes) | |
| 156 | + | w.CursorShapes = nil | |
| 157 | + | } | |
| 158 | + | if err = w.BindSeat(); err != nil { | |
| 159 | + | return nil, err | |
| 160 | + | } | |
| 161 | + | w.Shell.OnPing(func(serial uint32) { | |
| 162 | + | if e := w.Shell.Pong(serial); e != nil { | |
| 163 | + | w.feedbackFailure(e) | |
| 164 | + | } | |
| 165 | + | }) | |
| 166 | + | w.Surface, err = w.Compositor.CreateSurface() | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | if w.Viewporter != nil { | |
| 171 | + | w.Viewport, err = w.Viewporter.GetViewport(w.Surface) | |
| 172 | + | if err != nil { | |
| 173 | + | return nil, err | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | if w.ScaleManager != nil { | |
| 177 | + | w.Fractional, err = w.ScaleManager.GetFractionalScale(w.Surface) | |
| 178 | + | if err != nil { | |
| 179 | + | return nil, err | |
| 180 | + | } | |
| 181 | + | w.Fractional.OnPreferredScale(func(scale uint32) { | |
| 182 | + | if w.readerStarted.Load() { | |
| 183 | + | w.post(Event{Kind: PreferredScale, Scale: scale}) | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | if scale == 0 { | |
| 187 | + | w.feedbackFailure(fmt.Errorf("invalid fractional scale zero")) | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | if scale%120 != 0 && w.Viewport == nil { | |
| 191 | + | w.feedbackFailure(&CapabilityError{Name: viewporter.WpViewporterInterface, Cause: fmt.Errorf("fractional scale %d/120 requires viewporter", scale)}) | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | if w.Scale != float64(scale)/120 { | |
| 195 | + | w.Scale = float64(scale) / 120 | |
| 196 | + | w.Dirty = true | |
| 197 | + | } | |
| 198 | + | }) | |
| 199 | + | } | |
| 200 | + | w.Surface.OnPreferredBufferScale(func(factor int32) { | |
| 201 | + | if w.readerStarted.Load() { | |
| 202 | + | w.post(Event{Kind: BufferScale, Factor: factor}) | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | if w.Fractional == nil && factor > 0 && w.Scale != float64(factor) { | |
| 206 | + | w.Scale = float64(factor) | |
| 207 | + | w.Dirty = true | |
| 208 | + | } | |
| 209 | + | }) | |
| 210 | + | w.XdgSurface, err = w.Shell.GetXdgSurface(w.Surface) | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, err | |
| 213 | + | } | |
| 214 | + | w.Toplevel, err = w.XdgSurface.GetToplevel() | |
| 215 | + | if err != nil { | |
| 216 | + | return nil, err | |
| 217 | + | } | |
| 218 | + | w.Toplevel.OnClose(func() { | |
| 219 | + | if w.readerStarted.Load() { | |
| 220 | + | w.post(Event{Kind: CloseEvent}) | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | w.Closed = true | |
| 224 | + | }) | |
| 225 | + | w.Toplevel.OnConfigure(func(width, height int32, _ []byte) { | |
| 226 | + | if w.readerStarted.Load() { | |
| 227 | + | w.post(Event{Kind: ConfigureSize, Width: width, Height: height}) | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | if width > 0 && height > 0 && (w.Width != width || w.Height != height) { | |
| 231 | + | w.Width, w.Height, w.Dirty = width, height, true | |
| 232 | + | } | |
| 233 | + | }) | |
| 234 | + | w.XdgSurface.OnConfigure(func(serial uint32) { | |
| 235 | + | if w.readerStarted.Load() { | |
| 236 | + | w.post(Event{Kind: ConfigureSerial, Serial: serial}) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | if e := w.XdgSurface.AckConfigure(serial); e != nil { | |
| 240 | + | w.feedbackFailure(e) | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | w.Configured, w.FrameReady = true, true | |
| 244 | + | }) | |
| 245 | + | w.SyncSurface, err = w.SyncManager.GetSurface(w.Surface) | |
| 246 | + | if err != nil { | |
| 247 | + | return nil, err | |
| 248 | + | } | |
| 249 | + | w.Feedback, err = w.Dmabuf.GetDefaultFeedback() | |
| 250 | + | if err != nil { | |
| 251 | + | return nil, err | |
| 252 | + | } | |
| 253 | + | w.watchFeedback() | |
| 254 | + | if err = w.Surface.Commit(); err != nil { | |
| 255 | + | return nil, err | |
| 256 | + | } // no buffer before configure | |
| 257 | + | for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 { | |
| 258 | + | if w.Closed { | |
| 259 | + | return nil, fmt.Errorf("window closed before initial configure and feedback") | |
| 260 | + | } | |
| 261 | + | if err = w.Dispatch(); err != nil { | |
| 262 | + | return nil, err | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | return w, nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (w *Window) Dispatch() error { | |
| 269 | + | if w.feedbackErr != nil { | |
| 270 | + | return w.feedbackErr | |
| 271 | + | } | |
| 272 | + | if err := w.Display.Dispatch(); err != nil { | |
| 273 | + | return err | |
| 274 | + | } | |
| 275 | + | return w.feedbackErr | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (w *Window) PhysicalSize() (int32, int32, error) { | |
| 279 | + | if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 { | |
| 280 | + | return 0, 0, fmt.Errorf("invalid window geometry") | |
| 281 | + | } | |
| 282 | + | if w.Scale != math.Trunc(w.Scale) && w.Viewport == nil { | |
| 283 | + | return 0, 0, &CapabilityError{Name: viewporter.WpViewporterInterface, Cause: fmt.Errorf("fractional scale %g requires viewporter", w.Scale)} | |
| 284 | + | } | |
| 285 | + | // Ignore sub-nanopixel floating noise at exact integer boundaries. | |
| 286 | + | x, y := math.Ceil(float64(w.Width)*w.Scale-1e-9), math.Ceil(float64(w.Height)*w.Scale-1e-9) | |
| 287 | + | if x > math.MaxInt32 || y > math.MaxInt32 { | |
| 288 | + | return 0, 0, fmt.Errorf("buffer dimensions overflow") | |
| 289 | + | } | |
| 290 | + | return int32(x), int32(y), nil | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Present sets both syncobj points on every buffer commit. The caller supplies | |
| 294 | + | // an already-imported wl_buffer and two persistent Wayland timeline objects. | |
| 295 | + | func (w *Window) Present(buffer *core.Buffer, acquire, release *drmsyncobj.WpLinuxDrmSyncobjTimeline, acquirePoint, releasePoint uint64) error { | |
| 296 | + | if !w.Configured || buffer == nil || acquire == nil || release == nil { | |
| 297 | + | return fmt.Errorf("unconfigured surface or missing buffer/timeline") | |
| 298 | + | } | |
| 299 | + | if !w.FrameReady { | |
| 300 | + | return fmt.Errorf("frame callback pending") | |
| 301 | + | } | |
| 302 | + | pw, ph, err := w.PhysicalSize() | |
| 303 | + | if err != nil { | |
| 304 | + | return err | |
| 305 | + | } | |
| 306 | + | if w.Viewport != nil { | |
| 307 | + | if err = w.Viewport.SetDestination(w.Width, w.Height); err != nil { | |
| 308 | + | return err | |
| 309 | + | } | |
| 310 | + | } else if w.Scale > 1 { | |
| 311 | + | if err = w.Surface.SetBufferScale(int32(w.Scale)); err != nil { | |
| 312 | + | return err | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | if w.Transparent { | |
| 316 | + | if err = w.Surface.SetOpaqueRegion(nil); err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | } else { | |
| 320 | + | region, e := w.Compositor.CreateRegion() | |
| 321 | + | if e != nil { | |
| 322 | + | return e | |
| 323 | + | } | |
| 324 | + | if err = region.Add(0, 0, w.Width, w.Height); err == nil { | |
| 325 | + | err = w.Surface.SetOpaqueRegion(region) | |
| 326 | + | } | |
| 327 | + | _ = region.Destroy() | |
| 328 | + | if err != nil { | |
| 329 | + | return err | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | if err = w.SyncSurface.SetAcquirePoint(acquire, uint32(acquirePoint>>32), uint32(acquirePoint)); err != nil { | |
| 333 | + | return err | |
| 334 | + | } | |
| 335 | + | if err = w.SyncSurface.SetReleasePoint(release, uint32(releasePoint>>32), uint32(releasePoint)); err != nil { | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | if err = w.Surface.Attach(buffer, 0, 0); err != nil { | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | if err = w.Surface.DamageBuffer(0, 0, pw, ph); err != nil { | |
| 342 | + | return err | |
| 343 | + | } | |
| 344 | + | cb, e := w.Surface.Frame() | |
| 345 | + | if e != nil { | |
| 346 | + | return e | |
| 347 | + | } | |
| 348 | + | cb.OnDone(func(_ uint32) { | |
| 349 | + | if w.readerStarted.Load() { | |
| 350 | + | w.post(Event{Kind: FrameEvent}) | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | w.FrameReady = true | |
| 354 | + | }) | |
| 355 | + | if err = w.Surface.Commit(); err != nil { | |
| 356 | + | return err | |
| 357 | + | } | |
| 358 | + | w.FrameReady, w.Dirty = false, false | |
| 359 | + | return nil | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // DestroySyncSurface stops associating new explicit-sync points with the surface. | |
| 363 | + | // The session calls it after committing a null attachment at shutdown. | |
| 364 | + | func (w *Window) DestroySyncSurface() error { | |
| 365 | + | if w == nil || w.SyncSurface == nil { | |
| 366 | + | return nil | |
| 367 | + | } | |
| 368 | + | err := w.SyncSurface.Destroy() | |
| 369 | + | if err == nil { | |
| 370 | + | w.SyncSurface = nil | |
| 371 | + | } | |
| 372 | + | return err | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Close destroys children before their parents. The caller first detaches and | |
| 376 | + | // waits for its own GPU work before destroying its Vulkan allocations. | |
| 377 | + | func (w *Window) Close() error { | |
| 378 | + | if w == nil { | |
| 379 | + | return nil | |
| 380 | + | } | |
| 381 | + | // The loop sends protocol destructors before Close; the reader is | |
| 382 | + | // unblocked by the connection close at the end of this method. | |
| 383 | + | if w.inputAdapter != nil { | |
| 384 | + | w.inputAdapter.Close() | |
| 385 | + | w.inputAdapter = nil | |
| 386 | + | } | |
| 387 | + | if w.Seat != nil { | |
| 388 | + | _ = w.releaseSeat() | |
| 389 | + | } | |
| 390 | + | if w.CursorShapes != nil { | |
| 391 | + | _ = w.CursorShapes.Destroy() | |
| 392 | + | w.CursorShapes = nil | |
| 393 | + | } | |
| 394 | + | if w.Feedback != nil { | |
| 395 | + | _ = w.Feedback.Destroy() | |
| 396 | + | w.Feedback = nil | |
| 397 | + | } | |
| 398 | + | if w.SyncSurface != nil { | |
| 399 | + | _ = w.SyncSurface.Destroy() | |
| 400 | + | w.SyncSurface = nil | |
| 401 | + | } | |
| 402 | + | if w.Toplevel != nil { | |
| 403 | + | _ = w.Toplevel.Destroy() | |
| 404 | + | w.Toplevel = nil | |
| 405 | + | } | |
| 406 | + | if w.XdgSurface != nil { | |
| 407 | + | _ = w.XdgSurface.Destroy() | |
| 408 | + | w.XdgSurface = nil | |
| 409 | + | } | |
| 410 | + | if w.Fractional != nil { | |
| 411 | + | _ = w.Fractional.Destroy() | |
| 412 | + | w.Fractional = nil | |
| 413 | + | } | |
| 414 | + | if w.Viewport != nil { | |
| 415 | + | _ = w.Viewport.Destroy() | |
| 416 | + | w.Viewport = nil | |
| 417 | + | } | |
| 418 | + | if w.Surface != nil { | |
| 419 | + | _ = w.Surface.Destroy() | |
| 420 | + | w.Surface = nil | |
| 421 | + | } | |
| 422 | + | if w.SyncManager != nil { | |
| 423 | + | _ = w.SyncManager.Destroy() | |
| 424 | + | w.SyncManager = nil | |
| 425 | + | } | |
| 426 | + | if w.Dmabuf != nil { | |
| 427 | + | _ = w.Dmabuf.Destroy() | |
| 428 | + | w.Dmabuf = nil | |
| 429 | + | } | |
| 430 | + | if w.ScaleManager != nil { | |
| 431 | + | _ = w.ScaleManager.Destroy() | |
| 432 | + | w.ScaleManager = nil | |
| 433 | + | } | |
| 434 | + | if w.Viewporter != nil { | |
| 435 | + | _ = w.Viewporter.Destroy() | |
| 436 | + | w.Viewporter = nil | |
| 437 | + | } | |
| 438 | + | if w.Shell != nil { | |
| 439 | + | _ = w.Shell.Destroy() | |
| 440 | + | w.Shell = nil | |
| 441 | + | } | |
| 442 | + | // wl_compositor is bound at most at version 6; its release destructor | |
| 443 | + | // needs version 7, so the proxy is only forgotten locally. | |
| 444 | + | if w.Compositor != nil { | |
| 445 | + | if c := w.Compositor.Context(); c != nil { | |
| 446 | + | c.Unregister(w.Compositor) | |
| 447 | + | } | |
| 448 | + | w.Compositor = nil | |
| 449 | + | } | |
| 450 | + | if w.Display != nil { | |
| 451 | + | if w.readerDone != nil { | |
| 452 | + | close(w.readerStop) | |
| 453 | + | } | |
| 454 | + | err := w.Display.Close() | |
| 455 | + | if w.readerDone != nil { | |
| 456 | + | <-w.readerDone | |
| 457 | + | w.readerDone = nil | |
| 458 | + | } | |
| 459 | + | for len(w.events) > 0 { | |
| 460 | + | CloseEventFD(<-w.events) | |
| 461 | + | } | |
| 462 | + | w.Display = nil | |
| 463 | + | return err | |
| 464 | + | } | |
| 465 | + | return nil | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func (w *Window) watchFeedback() { | |
| 469 | + | var table []linuxdmabuf.FormatEntry | |
| 470 | + | var indices []uint16 | |
| 471 | + | w.Feedback.OnFormatTable(func(fd *wl.OwnedFD, size uint32) { | |
| 472 | + | var err error | |
| 473 | + | table, err = linuxdmabuf.ReadFormatTable(fd, size) | |
| 474 | + | if err != nil { | |
| 475 | + | w.feedbackFailure(err) | |
| 476 | + | } | |
| 477 | + | }) | |
| 478 | + | w.Feedback.OnMainDevice(func(device []byte) { | |
| 479 | + | if len(device) != 8 { | |
| 480 | + | w.feedbackFailure(fmt.Errorf("invalid dmabuf main_device length %d", len(device))) | |
| 481 | + | return | |
| 482 | + | } | |
| 483 | + | if w.readerStarted.Load() { | |
| 484 | + | w.post(Event{Kind: FeedbackMainDevice, Device: binary.NativeEndian.Uint64(device)}) | |
| 485 | + | } else { | |
| 486 | + | w.MainDevice = binary.NativeEndian.Uint64(device) | |
| 487 | + | } | |
| 488 | + | }) | |
| 489 | + | w.Feedback.OnTrancheFormats(func(data []byte) { | |
| 490 | + | var err error | |
| 491 | + | indices, err = linuxdmabuf.TrancheIndices(data, len(table)) | |
| 492 | + | if err != nil { | |
| 493 | + | w.feedbackFailure(err) | |
| 494 | + | } | |
| 495 | + | }) | |
| 496 | + | w.Feedback.OnTrancheDone(func() { | |
| 497 | + | var tranche []linuxdmabuf.FormatEntry | |
| 498 | + | for _, i := range indices { | |
| 499 | + | tranche = append(tranche, table[i]) | |
| 500 | + | } | |
| 501 | + | if w.readerStarted.Load() { | |
| 502 | + | w.post(Event{Kind: FeedbackTranche, Tranche: tranche}) | |
| 503 | + | } else { | |
| 504 | + | w.Tranches = append(w.Tranches, tranche) | |
| 505 | + | } | |
| 506 | + | indices = nil | |
| 507 | + | }) | |
| 508 | + | w.Feedback.OnDone(func() { | |
| 509 | + | if w.readerStarted.Load() { | |
| 510 | + | w.post(Event{Kind: FeedbackFormats, Formats: table}) | |
| 511 | + | } else { | |
| 512 | + | w.Formats = table | |
| 513 | + | } | |
| 514 | + | }) | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | func (w *Window) feedbackFailure(err error) { | |
| 518 | + | if w.readerStarted.Load() { | |
| 519 | + | w.post(Event{Kind: FeedbackError, Err: err}) | |
| 520 | + | } else { | |
| 521 | + | w.feedbackErr = err | |
| 522 | + | } | |
| 523 | + | } | |
A internal/presentation/buffers/buffers.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | // Package buffers models Wayland/Vulkan buffer ownership without GPU dependencies. | |
| 2 | + | // The caller serializes events from the renderer, the Wayland dispatch loop, and DRM. | |
| 3 | + | package buffers | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ErrNoBuffer = errors.New("no available buffer") | |
| 11 | + | var ErrTransition = errors.New("invalid buffer transition") | |
| 12 | + | ||
| 13 | + | type State string | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | Available State = "available" | |
| 17 | + | Rendering State = "rendering" | |
| 18 | + | Acquired State = "acquire_exported" | |
| 19 | + | Committed State = "committed" | |
| 20 | + | CompositorOwned State = "compositor_owned" | |
| 21 | + | Released State = "release_point_signaled" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // Buffer is an immutable identity and mutable ownership record. On an explicit | |
| 25 | + | // sync surface wl_buffer.release is optional; only the latest release point of | |
| 26 | + | // this buffer's own timeline authorizes reuse. | |
| 27 | + | type Buffer struct { | |
| 28 | + | ID, Generation uint64 | |
| 29 | + | State State | |
| 30 | + | AcquirePoint, ReleasePoint uint64 | |
| 31 | + | WlReleased, PointSignaled bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type Pool struct { | |
| 35 | + | Generation uint64 | |
| 36 | + | NextPoint uint64 | |
| 37 | + | Buffers []*Buffer | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func New(count int) (*Pool, error) { | |
| 41 | + | if count < 1 { | |
| 42 | + | return nil, fmt.Errorf("buffer count must be positive") | |
| 43 | + | } | |
| 44 | + | p := &Pool{Generation: 1} | |
| 45 | + | for i := 0; i < count; i++ { | |
| 46 | + | p.Buffers = append(p.Buffers, &Buffer{ID: uint64(i + 1), Generation: 1, State: Available}) | |
| 47 | + | } | |
| 48 | + | return p, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Begin skips rendering when every buffer is busy. Retired generations are | |
| 52 | + | // never selected, even if their release events complete later. | |
| 53 | + | func (p *Pool) Begin() (*Buffer, error) { | |
| 54 | + | for _, b := range p.Buffers { | |
| 55 | + | if b.Generation == p.Generation && b.State == Available { | |
| 56 | + | b.State = Rendering | |
| 57 | + | b.WlReleased, b.PointSignaled = false, false | |
| 58 | + | return b, nil | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | return nil, ErrNoBuffer | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (p *Pool) Acquire(b *Buffer) (uint64, error) { | |
| 65 | + | if !p.owns(b) || b.State != Rendering || b.Generation != p.Generation { | |
| 66 | + | return 0, ErrTransition | |
| 67 | + | } | |
| 68 | + | p.NextPoint++ | |
| 69 | + | b.AcquirePoint = p.NextPoint | |
| 70 | + | b.State = Acquired | |
| 71 | + | return b.AcquirePoint, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (p *Pool) Commit(b *Buffer) (uint64, error) { | |
| 75 | + | if !p.owns(b) || b.State != Acquired || b.Generation != p.Generation { | |
| 76 | + | return 0, ErrTransition | |
| 77 | + | } | |
| 78 | + | p.NextPoint++ | |
| 79 | + | b.ReleasePoint = p.NextPoint | |
| 80 | + | b.PointSignaled = false | |
| 81 | + | b.WlReleased = false | |
| 82 | + | b.State = Committed | |
| 83 | + | return b.ReleasePoint, nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (p *Pool) Own(b *Buffer) error { | |
| 87 | + | if !p.owns(b) || b.State != Committed { | |
| 88 | + | return ErrTransition | |
| 89 | + | } | |
| 90 | + | b.State = CompositorOwned | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Release records an optional wl_buffer.release event. It never authorizes | |
| 95 | + | // reuse, and a late event from a prior commit must not modify the new commit. | |
| 96 | + | func (p *Pool) Release(b *Buffer) error { | |
| 97 | + | if !p.owns(b) { | |
| 98 | + | return ErrTransition | |
| 99 | + | } | |
| 100 | + | if b.State == Committed || b.State == CompositorOwned { | |
| 101 | + | b.WlReleased = true | |
| 102 | + | } | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (p *Pool) Signal(b *Buffer, point uint64) error { | |
| 107 | + | if !p.owns(b) || (b.State != Committed && b.State != CompositorOwned) || b.PointSignaled || point != b.ReleasePoint { | |
| 108 | + | return ErrTransition | |
| 109 | + | } | |
| 110 | + | b.PointSignaled = true | |
| 111 | + | b.State = Released | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Reuse requires the caller to have arranged a GPU wait on the exported | |
| 116 | + | // release-point sync_file before submitting new writes to this image. | |
| 117 | + | func (p *Pool) Reuse(b *Buffer, gpuWaitInstalled bool) error { | |
| 118 | + | if !p.owns(b) || b.Generation != p.Generation || b.State != Released || !gpuWaitInstalled { | |
| 119 | + | return ErrTransition | |
| 120 | + | } | |
| 121 | + | b.State = Available | |
| 122 | + | return nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Resize installs an entirely new generation. Old images remain tracked until | |
| 126 | + | // their latest release point signals; even Available old images are retired. | |
| 127 | + | func (p *Pool) Resize(count int) error { | |
| 128 | + | if count < 1 { | |
| 129 | + | return fmt.Errorf("buffer count must be positive") | |
| 130 | + | } | |
| 131 | + | p.Generation++ | |
| 132 | + | var next uint64 | |
| 133 | + | for _, b := range p.Buffers { | |
| 134 | + | if b.ID > next { | |
| 135 | + | next = b.ID | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | for i := 0; i < count; i++ { | |
| 139 | + | next++ | |
| 140 | + | p.Buffers = append(p.Buffers, &Buffer{ID: next, Generation: p.Generation, State: Available}) | |
| 141 | + | } | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Retirable reports when the compositor and GPU no longer own a retired image. | |
| 146 | + | // An abandoned Rendering or Acquired image still needs the caller to finish or | |
| 147 | + | // wait for its Vulkan submission before destroying the GPU allocation. | |
| 148 | + | func (p *Pool) Retirable(b *Buffer) bool { | |
| 149 | + | return p.owns(b) && b.Generation < p.Generation && (b.State == Available || b.State == Released) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (p *Pool) Remove(b *Buffer) error { | |
| 153 | + | if !p.Retirable(b) { | |
| 154 | + | return ErrTransition | |
| 155 | + | } | |
| 156 | + | for i, other := range p.Buffers { | |
| 157 | + | if other == b { | |
| 158 | + | p.Buffers = append(p.Buffers[:i], p.Buffers[i+1:]...) | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return ErrTransition | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (p *Pool) owns(b *Buffer) bool { | |
| 166 | + | if b == nil { | |
| 167 | + | return false | |
| 168 | + | } | |
| 169 | + | for _, other := range p.Buffers { | |
| 170 | + | if other == b { | |
| 171 | + | return true | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return false | |
| 175 | + | } | |
A internal/presentation/buffers/buffers_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package buffers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func committed(t *testing.T, p *Pool) *Buffer { | |
| 9 | + | t.Helper() | |
| 10 | + | b, e := p.Begin() | |
| 11 | + | if e != nil { | |
| 12 | + | t.Fatal(e) | |
| 13 | + | } | |
| 14 | + | acquire, e := p.Acquire(b) | |
| 15 | + | if e != nil { | |
| 16 | + | t.Fatal(e) | |
| 17 | + | } | |
| 18 | + | release, e := p.Commit(b) | |
| 19 | + | if e != nil || release <= acquire { | |
| 20 | + | t.Fatalf("points %d %d %v", acquire, release, e) | |
| 21 | + | } | |
| 22 | + | if e = p.Own(b); e != nil { | |
| 23 | + | t.Fatal(e) | |
| 24 | + | } | |
| 25 | + | return b | |
| 26 | + | } | |
| 27 | + | func TestLatestReleasePoint(t *testing.T) { | |
| 28 | + | p, e := New(1) | |
| 29 | + | if e != nil { | |
| 30 | + | t.Fatal(e) | |
| 31 | + | } | |
| 32 | + | b := committed(t, p) | |
| 33 | + | old := b.ReleasePoint | |
| 34 | + | if e = p.Release(b); e != nil { | |
| 35 | + | t.Fatal(e) | |
| 36 | + | } | |
| 37 | + | if _, e = p.Begin(); !errors.Is(e, ErrNoBuffer) { | |
| 38 | + | t.Fatalf("wl release authorized reuse: %v", e) | |
| 39 | + | } | |
| 40 | + | if e = p.Signal(b, old+1); !errors.Is(e, ErrTransition) { | |
| 41 | + | t.Fatalf("future point: %v", e) | |
| 42 | + | } | |
| 43 | + | if e = p.Reuse(b, true); !errors.Is(e, ErrTransition) { | |
| 44 | + | t.Fatalf("premature reuse: %v", e) | |
| 45 | + | } | |
| 46 | + | if e = p.Signal(b, old); e != nil { | |
| 47 | + | t.Fatal(e) | |
| 48 | + | } | |
| 49 | + | if e = p.Reuse(b, false); !errors.Is(e, ErrTransition) { | |
| 50 | + | t.Fatalf("missing GPU wait: %v", e) | |
| 51 | + | } | |
| 52 | + | if e = p.Reuse(b, true); e != nil { | |
| 53 | + | t.Fatal(e) | |
| 54 | + | } | |
| 55 | + | b = committed(t, p) | |
| 56 | + | if b.ReleasePoint <= old { | |
| 57 | + | t.Fatal("release point not advanced") | |
| 58 | + | } | |
| 59 | + | if e = p.Signal(b, old); !errors.Is(e, ErrTransition) { | |
| 60 | + | t.Fatalf("old point accepted: %v", e) | |
| 61 | + | } | |
| 62 | + | if _, e = p.Begin(); !errors.Is(e, ErrNoBuffer) { | |
| 63 | + | t.Fatalf("old point allowed reuse: %v", e) | |
| 64 | + | } | |
| 65 | + | if e = p.Signal(b, b.ReleasePoint); e != nil { | |
| 66 | + | t.Fatal(e) | |
| 67 | + | } | |
| 68 | + | // A late wl_buffer.release is diagnostic and cannot re-lock a signaled image. | |
| 69 | + | if e = p.Release(b); e != nil { | |
| 70 | + | t.Fatal(e) | |
| 71 | + | } | |
| 72 | + | if e = p.Reuse(b, true); e != nil { | |
| 73 | + | t.Fatal(e) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | func TestThreeBuffersOutOfOrderAndResize(t *testing.T) { | |
| 77 | + | p, e := New(3) | |
| 78 | + | if e != nil { | |
| 79 | + | t.Fatal(e) | |
| 80 | + | } | |
| 81 | + | a, b, c := committed(t, p), committed(t, p), committed(t, p) | |
| 82 | + | if _, e = p.Begin(); !errors.Is(e, ErrNoBuffer) { | |
| 83 | + | t.Fatal(e) | |
| 84 | + | } | |
| 85 | + | if e = p.Signal(c, c.ReleasePoint); e != nil { | |
| 86 | + | t.Fatal(e) | |
| 87 | + | } | |
| 88 | + | if e = p.Reuse(c, true); e != nil { | |
| 89 | + | t.Fatal(e) | |
| 90 | + | } | |
| 91 | + | if got, e := p.Begin(); e != nil || got != c { | |
| 92 | + | t.Fatalf("out-of-order point: %v %v", got, e) | |
| 93 | + | } | |
| 94 | + | if e = p.Resize(3); e != nil { | |
| 95 | + | t.Fatal(e) | |
| 96 | + | } | |
| 97 | + | if e = p.Signal(b, b.ReleasePoint); e != nil { | |
| 98 | + | t.Fatal(e) | |
| 99 | + | } | |
| 100 | + | if !p.Retirable(b) || p.Retirable(a) || p.Retirable(c) { | |
| 101 | + | t.Fatal("retired generation ownership") | |
| 102 | + | } | |
| 103 | + | if e = p.Reuse(b, true); !errors.Is(e, ErrTransition) { | |
| 104 | + | t.Fatalf("reused retired: %v", e) | |
| 105 | + | } | |
| 106 | + | for range 3 { | |
| 107 | + | got, e := p.Begin() | |
| 108 | + | if e != nil || got.Generation != 2 { | |
| 109 | + | t.Fatalf("new generation: %v %v", got, e) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | if _, e = p.Begin(); !errors.Is(e, ErrNoBuffer) { | |
| 113 | + | t.Fatal(e) | |
| 114 | + | } | |
| 115 | + | if e = p.Remove(a); !errors.Is(e, ErrTransition) { | |
| 116 | + | t.Fatal(e) | |
| 117 | + | } | |
| 118 | + | if e = p.Remove(b); e != nil { | |
| 119 | + | t.Fatal(e) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | func TestInvalidTransitions(t *testing.T) { | |
| 123 | + | p, _ := New(1) | |
| 124 | + | b := p.Buffers[0] | |
| 125 | + | if _, e := p.Commit(b); !errors.Is(e, ErrTransition) { | |
| 126 | + | t.Fatal(e) | |
| 127 | + | } | |
| 128 | + | if e := p.Signal(b, 0); !errors.Is(e, ErrTransition) { | |
| 129 | + | t.Fatal(e) | |
| 130 | + | } | |
| 131 | + | if e := p.Release(&Buffer{}); !errors.Is(e, ErrTransition) { | |
| 132 | + | t.Fatal(e) | |
| 133 | + | } | |
| 134 | + | b = committed(t, p) | |
| 135 | + | if e := p.Signal(b, b.ReleasePoint); e != nil { | |
| 136 | + | t.Fatal(e) | |
| 137 | + | } | |
| 138 | + | if e := p.Signal(b, b.ReleasePoint); !errors.Is(e, ErrTransition) { | |
| 139 | + | t.Fatal(e) | |
| 140 | + | } | |
| 141 | + | } | |
A internal/presentation/session/app.go +159 -0 collapsed to keep the page light
A internal/presentation/session/close_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package session | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // A requested debug artifact that cannot be written must fail Close, so Run | |
| 12 | + | // cannot report success with missing evidence. | |
| 13 | + | func TestCloseReportsDebugSummaryWriteError(t *testing.T) { | |
| 14 | + | s := &Session{debugDir: filepath.Join(t.TempDir(), "missing")} | |
| 15 | + | err := s.Close() | |
| 16 | + | if err == nil || !strings.Contains(err.Error(), "resource summary") { | |
| 17 | + | t.Fatalf("Close error = %v, want resource summary write error", err) | |
| 18 | + | } | |
| 19 | + | ok := &Session{debugDir: t.TempDir()} | |
| 20 | + | if err := ok.Close(); err != nil { | |
| 21 | + | t.Fatalf("Close with writable debug dir: %v", err) | |
| 22 | + | } | |
| 23 | + | } | |
A internal/presentation/session/readback_test.go +75 -0 collapsed to keep the page light
A internal/presentation/session/readiness_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package session | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "github.com/bnema/nefergui/internal/presentation/buffers" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBeginReadySkipsUnfinishedGPU(t *testing.T) { | |
| 11 | + | pool, _ := buffers.New(2) | |
| 12 | + | slots := map[uint64]*Slot{1: {}, 2: {}} | |
| 13 | + | calls := 0 | |
| 14 | + | check := func(slot *Slot) (bool, error) { calls++; return slot == slots[2], nil } | |
| 15 | + | b, slot, waiting, err := beginReady(pool, slots, check) | |
| 16 | + | if err != nil || b != pool.Buffers[1] || slot != slots[2] || waiting || calls != 2 { | |
| 17 | + | t.Fatalf("selection b=%v slot=%v waiting=%v calls=%d err=%v", b, slot, waiting, calls, err) | |
| 18 | + | } | |
| 19 | + | if pool.Buffers[0].State != buffers.Available || pool.Buffers[1].State != buffers.Rendering { | |
| 20 | + | t.Fatalf("states: %v", pool.Buffers) | |
| 21 | + | } | |
| 22 | + | pool.Buffers[1].State = buffers.CompositorOwned | |
| 23 | + | b, _, waiting, err = beginReady(pool, slots, func(*Slot) (bool, error) { return false, nil }) | |
| 24 | + | if err != nil || b != nil || !waiting || pool.Buffers[0].State != buffers.Available { | |
| 25 | + | t.Fatalf("unready mutated pool: %v %v %v", b, waiting, err) | |
| 26 | + | } | |
| 27 | + | b, _, waiting, err = beginReady(pool, slots, func(*Slot) (bool, error) { return true, nil }) | |
| 28 | + | if err != nil || b != pool.Buffers[0] || waiting { | |
| 29 | + | t.Fatalf("retry: b=%v waiting=%v err=%v", b, waiting, err) | |
| 30 | + | } | |
| 31 | + | } | |
A internal/presentation/session/session.go +917 -0 collapsed to keep the page light
A internal/presentation/session/session_test.go +26 -0 collapsed to keep the page light
A internal/presentation/shaders/freshness_test.go +44 -0 collapsed to keep the page light
A internal/presentation/shaders/list.frag +109 -0 collapsed to keep the page light
A internal/presentation/shaders/list.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
A internal/presentation/shaders/list.vert +49 -0 collapsed to keep the page light
A internal/presentation/shaders/list.vert.spv +0 -0 binary file: no text diff
binary file: no text diff
A internal/presentation/shaders/rect.frag +9 -0 collapsed to keep the page light
A internal/presentation/shaders/rect.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
A internal/presentation/shaders/rect.vert +12 -0 collapsed to keep the page light
A internal/presentation/shaders/rect.vert.spv +0 -0 binary file: no text diff
binary file: no text diff
A internal/presentation/shaders/shaders.go +22 -0 collapsed to keep the page light
A internal/presentation/syncobj/bridge_test.go +98 -0 collapsed to keep the page light
A internal/presentation/syncobj/syncobj.go +207 -0 collapsed to keep the page light
A internal/presentation/syncobj/syncobj_test.go +104 -0 collapsed to keep the page light
A internal/presentation/systemtest/demo_test.go +258 -0 collapsed to keep the page light
A internal/presentation/systemtest/image_test.go +113 -0 collapsed to keep the page light
A internal/presentation/systemtest/system_test.go +417 -0 collapsed to keep the page light
A internal/presentation/systemtest/text_test.go +157 -0 collapsed to keep the page light
A internal/presentation/vkdevice/atlas.go +225 -0 collapsed to keep the page light
A internal/presentation/vkdevice/device.go +251 -0 collapsed to keep the page light
A internal/presentation/vkdevice/device_test.go +63 -0 collapsed to keep the page light
A internal/presentation/vkdevice/fence.go +80 -0 collapsed to keep the page light
A internal/presentation/vkdevice/image.go +131 -0 collapsed to keep the page light
A internal/presentation/vkdevice/image_cache.go +366 -0 collapsed to keep the page light
A internal/presentation/vkdevice/image_cache_policy.go +119 -0 collapsed to keep the page light
A internal/presentation/vkdevice/image_cache_policy_test.go +167 -0 collapsed to keep the page light
A internal/presentation/vkdevice/instance_test.go +20 -0 collapsed to keep the page light
A internal/presentation/vkdevice/instances.go +81 -0 collapsed to keep the page light
A internal/presentation/vkdevice/modifier.go +76 -0 collapsed to keep the page light
A internal/presentation/vkdevice/modifier_test.go +58 -0 collapsed to keep the page light
A internal/presentation/vkdevice/pipeline.go +190 -0 collapsed to keep the page light
A internal/presentation/vkdevice/pipeline_test.go +35 -0 collapsed to keep the page light
A internal/presentation/vkdevice/readback.go +104 -0 collapsed to keep the page light
A internal/presentation/vkdevice/readback_test.go +20 -0 collapsed to keep the page light
A internal/presentation/vkdevice/render.go +316 -0 collapsed to keep the page light
A internal/presentation/vkdevice/validation.go +133 -0 collapsed to keep the page light
A internal/presentation/vkdevice/validation_test.go +42 -0 collapsed to keep the page light
A internal/render/benchmark_test.go +53 -0 collapsed to keep the page light
A internal/render/instances.go +77 -0 collapsed to keep the page light
A internal/render/instances_test.go +46 -0 collapsed to keep the page light
A internal/render/prepare.go +182 -0 collapsed to keep the page light
A internal/render/prepare_test.go +102 -0 collapsed to keep the page light
A internal/render/styles_test.go +59 -0 collapsed to keep the page light
A internal/text/atlas.go +177 -0 collapsed to keep the page light
A internal/text/atlas_dirty_test.go +33 -0 collapsed to keep the page light
A internal/text/font_lazy_test.go +317 -0 collapsed to keep the page light
A internal/text/fonts.go +661 -0 collapsed to keep the page light
A internal/text/golden_test.go +73 -0 collapsed to keep the page light
A internal/text/measure_cache.go +102 -0 collapsed to keep the page light
A internal/text/measure_cache_test.go +82 -0 collapsed to keep the page light
A internal/text/paragraph.go +91 -0 collapsed to keep the page light
A internal/text/paragraph_test.go +100 -0 collapsed to keep the page light
A internal/text/raster.go +160 -0 collapsed to keep the page light
A internal/text/review_test.go +244 -0 collapsed to keep the page light
A internal/text/shape.go +372 -0 collapsed to keep the page light
A internal/text/text_test.go +436 -0 collapsed to keep the page light
A internal/text/visual.go +211 -0 collapsed to keep the page light
A internal/text/visual_test.go +140 -0 collapsed to keep the page light
A options.go +70 -0 collapsed to keep the page light
A paste.go +34 -0 collapsed to keep the page light
A paste_test.go +74 -0 collapsed to keep the page light
A performance_test.go +73 -0 collapsed to keep the page light
A platform.go +32 -0 collapsed to keep the page light
A runtime.go +449 -0 collapsed to keep the page light
A runtime_test.go +289 -0 collapsed to keep the page light
A slider_grid_test.go +74 -0 collapsed to keep the page light
A slider_pointer_test.go +40 -0 collapsed to keep the page light
A testdata/compilefail/button_placeholder.go.txt +3 -0 collapsed to keep the page light
A testdata/compilefail/input_level.go.txt +3 -0 collapsed to keep the page light
A testdata/control-display/button_disabled.json +147 -0 collapsed to keep the page light
A testdata/control-display/button_focus-visible.json +202 -0 collapsed to keep the page light
A testdata/control-display/button_hover.json +147 -0 collapsed to keep the page light
A testdata/control-display/button_normal.json +147 -0 collapsed to keep the page light
A testdata/control-display/checkbox_checked.json +257 -0 collapsed to keep the page light
A testdata/control-display/checkbox_disabled.json +202 -0 collapsed to keep the page light
A testdata/control-display/checkbox_focus-visible.json +257 -0 collapsed to keep the page light
A testdata/control-display/checkbox_hover.json +202 -0 collapsed to keep the page light
A testdata/control-display/checkbox_normal.json +202 -0 collapsed to keep the page light
A testdata/control-display/heading_hover.json +147 -0 collapsed to keep the page light
A testdata/control-display/heading_normal.json +147 -0 collapsed to keep the page light
A testdata/control-display/icon_hover.json +57 -0 collapsed to keep the page light
A testdata/control-display/icon_normal.json +57 -0 collapsed to keep the page light
A testdata/control-display/image_hover.json +112 -0 collapsed to keep the page light
A testdata/control-display/image_normal.json +112 -0 collapsed to keep the page light
A testdata/control-display/input_disabled.json +257 -0 collapsed to keep the page light
A testdata/control-display/input_focus-visible.json +367 -0 collapsed to keep the page light
A testdata/control-display/input_hover.json +257 -0 collapsed to keep the page light
A testdata/control-display/input_normal.json +257 -0 collapsed to keep the page light
A testdata/control-display/input_preedit.json +498 -0
| @@ -0,0 +1,498 @@ | |||
| 1 | + | [ | |
| 2 | + | { | |
| 3 | + | "Op": "rect", | |
| 4 | + | "ID": "root/p0:5:input", | |
| 5 | + | "Rect": { | |
| 6 | + | "X": 0, | |
| 7 | + | "Y": 0, | |
| 8 | + | "W": 120, | |
| 9 | + | "H": 28 | |
| 10 | + | }, | |
| 11 | + | "Color": { | |
| 12 | + | "R": 0.8666666666666667, | |
| 13 | + | "G": 0.9333333333333333, | |
| 14 | + | "B": 1, | |
| 15 | + | "A": 1 | |
| 16 | + | }, | |
| 17 | + | "Opacity": 1, | |
| 18 | + | "Widths": { | |
| 19 | + | "Top": 0, | |
| 20 | + | "Right": 0, | |
| 21 | + | "Bottom": 0, | |
| 22 | + | "Left": 0 | |
| 23 | + | }, | |
| 24 | + | "Colors": { | |
| 25 | + | "Top": { | |
| 26 | + | "R": 0, | |
| 27 | + | "G": 0, | |
| 28 | + | "B": 0, | |
| 29 | + | "A": 0 | |
| 30 | + | }, | |
| 31 | + | "Right": { | |
| 32 | + | "R": 0, | |
| 33 | + | "G": 0, | |
| 34 | + | "B": 0, | |
| 35 | + | "A": 0 | |
| 36 | + | }, | |
| 37 | + | "Bottom": { | |
| 38 | + | "R": 0, | |
| 39 | + | "G": 0, | |
| 40 | + | "B": 0, | |
| 41 | + | "A": 0 | |
| 42 | + | }, | |
| 43 | + | "Left": { | |
| 44 | + | "R": 0, | |
| 45 | + | "G": 0, | |
| 46 | + | "B": 0, | |
| 47 | + | "A": 0 | |
| 48 | + | } | |
| 49 | + | }, | |
| 50 | + | "Radii": [ | |
| 51 | + | 0, | |
| 52 | + | 0, | |
| 53 | + | 0, | |
| 54 | + | 0 | |
| 55 | + | ] | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | "Op": "clip-push", | |
| 59 | + | "ID": "root/p0:5:input", | |
| 60 | + | "Rect": { | |
| 61 | + | "X": 0, | |
| 62 | + | "Y": 0, | |
| 63 | + | "W": 120, | |
| 64 | + | "H": 28 | |
| 65 | + | }, | |
| 66 | + | "Color": { | |
| 67 | + | "R": 0, | |
| 68 | + | "G": 0, | |
| 69 | + | "B": 0, | |
| 70 | + | "A": 0 | |
| 71 | + | }, | |
| 72 | + | "Opacity": 1, | |
| 73 | + | "Widths": { | |
| 74 | + | "Top": 0, | |
| 75 | + | "Right": 0, | |
| 76 | + | "Bottom": 0, | |
| 77 | + | "Left": 0 | |
| 78 | + | }, | |
| 79 | + | "Colors": { | |
| 80 | + | "Top": { | |
| 81 | + | "R": 0, | |
| 82 | + | "G": 0, | |
| 83 | + | "B": 0, | |
| 84 | + | "A": 0 | |
| 85 | + | }, | |
| 86 | + | "Right": { | |
| 87 | + | "R": 0, | |
| 88 | + | "G": 0, | |
| 89 | + | "B": 0, | |
| 90 | + | "A": 0 | |
| 91 | + | }, | |
| 92 | + | "Bottom": { | |
| 93 | + | "R": 0, | |
| 94 | + | "G": 0, | |
| 95 | + | "B": 0, | |
| 96 | + | "A": 0 | |
| 97 | + | }, | |
| 98 | + | "Left": { | |
| 99 | + | "R": 0, | |
| 100 | + | "G": 0, | |
| 101 | + | "B": 0, | |
| 102 | + | "A": 0 | |
| 103 | + | } | |
| 104 | + | }, | |
| 105 | + | "Radii": [ | |
| 106 | + | 0, | |
| 107 | + | 0, | |
| 108 | + | 0, | |
| 109 | + | 0 | |
| 110 | + | ] | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | "Op": "text", | |
| 114 | + | "ID": "root/p0:5:input", | |
| 115 | + | "Rect": { | |
| 116 | + | "X": 0, | |
| 117 | + | "Y": 0, | |
| 118 | + | "W": 120, | |
| 119 | + | "H": 28 | |
| 120 | + | }, | |
| 121 | + | "Color": { | |
| 122 | + | "R": 0, | |
| 123 | + | "G": 0, | |
| 124 | + | "B": 0, | |
| 125 | + | "A": 1 | |
| 126 | + | }, | |
| 127 | + | "Opacity": 1, | |
| 128 | + | "Widths": { | |
| 129 | + | "Top": 0, | |
| 130 | + | "Right": 0, | |
| 131 | + | "Bottom": 0, | |
| 132 | + | "Left": 0 | |
| 133 | + | }, | |
| 134 | + | "Colors": { | |
| 135 | + | "Top": { | |
| 136 | + | "R": 0, | |
| 137 | + | "G": 0, | |
| 138 | + | "B": 0, | |
| 139 | + | "A": 0 | |
| 140 | + | }, | |
| 141 | + | "Right": { | |
| 142 | + | "R": 0, | |
| 143 | + | "G": 0, | |
| 144 | + | "B": 0, | |
| 145 | + | "A": 0 | |
| 146 | + | }, | |
| 147 | + | "Bottom": { | |
| 148 | + | "R": 0, | |
| 149 | + | "G": 0, | |
| 150 | + | "B": 0, | |
| 151 | + | "A": 0 | |
| 152 | + | }, | |
| 153 | + | "Left": { | |
| 154 | + | "R": 0, | |
| 155 | + | "G": 0, | |
| 156 | + | "B": 0, | |
| 157 | + | "A": 0 | |
| 158 | + | } | |
| 159 | + | }, | |
| 160 | + | "Radii": [ | |
| 161 | + | 0, | |
| 162 | + | 0, | |
| 163 | + | 0, | |
| 164 | + | 0 | |
| 165 | + | ], | |
| 166 | + | "Text": "abc", | |
| 167 | + | "Runs": [ | |
| 168 | + | { | |
| 169 | + | "Start": 0, | |
| 170 | + | "End": 3, | |
| 171 | + | "X": 0, | |
| 172 | + | "Y": 17.104, | |
| 173 | + | "Advance": 26.5, | |
| 174 | + | "FaceID": "478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823/0", | |
| 175 | + | "Size": 16, | |
| 176 | + | "Glyphs": [ | |
| 177 | + | { | |
| 178 | + | "ID": 68, | |
| 179 | + | "X": 0, | |
| 180 | + | "Y": 17.104, | |
| 181 | + | "Advance": 8.96875, | |
| 182 | + | "Cluster": 0 | |
| 183 | + | }, | |
| 184 | + | { | |
| 185 | + | "ID": 69, | |
| 186 | + | "X": 8.96875, | |
| 187 | + | "Y": 17.104, | |
| 188 | + | "Advance": 9.84375, | |
| 189 | + | "Cluster": 1 | |
| 190 | + | }, | |
| 191 | + | { | |
| 192 | + | "ID": 70, | |
| 193 | + | "X": 18.8125, | |
| 194 | + | "Y": 17.104, | |
| 195 | + | "Advance": 7.6875, | |
| 196 | + | "Cluster": 2 | |
| 197 | + | } | |
| 198 | + | ] | |
| 199 | + | } | |
| 200 | + | ] | |
| 201 | + | }, | |
| 202 | + | { | |
| 203 | + | "Op": "caret", | |
| 204 | + | "ID": "root/p0:5:input", | |
| 205 | + | "Rect": { | |
| 206 | + | "X": 26.5, | |
| 207 | + | "Y": 0, | |
| 208 | + | "W": 1, | |
| 209 | + | "H": 21.792 | |
| 210 | + | }, | |
| 211 | + | "Color": { | |
| 212 | + | "R": 0, | |
| 213 | + | "G": 0, | |
| 214 | + | "B": 0, | |
| 215 | + | "A": 1 | |
| 216 | + | }, | |
| 217 | + | "Opacity": 1, | |
| 218 | + | "Widths": { | |
| 219 | + | "Top": 0, | |
| 220 | + | "Right": 0, | |
| 221 | + | "Bottom": 0, | |
| 222 | + | "Left": 0 | |
| 223 | + | }, | |
| 224 | + | "Colors": { | |
| 225 | + | "Top": { | |
| 226 | + | "R": 0, | |
| 227 | + | "G": 0, | |
| 228 | + | "B": 0, | |
| 229 | + | "A": 0 | |
| 230 | + | }, | |
| 231 | + | "Right": { | |
| 232 | + | "R": 0, | |
| 233 | + | "G": 0, | |
| 234 | + | "B": 0, | |
| 235 | + | "A": 0 | |
| 236 | + | }, | |
| 237 | + | "Bottom": { | |
| 238 | + | "R": 0, | |
| 239 | + | "G": 0, | |
| 240 | + | "B": 0, | |
| 241 | + | "A": 0 | |
| 242 | + | }, | |
| 243 | + | "Left": { | |
| 244 | + | "R": 0, | |
| 245 | + | "G": 0, | |
| 246 | + | "B": 0, | |
| 247 | + | "A": 0 | |
| 248 | + | } | |
| 249 | + | }, | |
| 250 | + | "Radii": [ | |
| 251 | + | 0, | |
| 252 | + | 0, | |
| 253 | + | 0, | |
| 254 | + | 0 | |
| 255 | + | ] | |
| 256 | + | }, | |
| 257 | + | { | |
| 258 | + | "Op": "preedit-text", | |
| 259 | + | "ID": "root/p0:5:input", | |
| 260 | + | "Rect": { | |
| 261 | + | "X": 26.5, | |
| 262 | + | "Y": 0, | |
| 263 | + | "W": 9.03125, | |
| 264 | + | "H": 21.792 | |
| 265 | + | }, | |
| 266 | + | "Color": { | |
| 267 | + | "R": 0, | |
| 268 | + | "G": 0, | |
| 269 | + | "B": 0, | |
| 270 | + | "A": 1 | |
| 271 | + | }, | |
| 272 | + | "Opacity": 1, | |
| 273 | + | "Widths": { | |
| 274 | + | "Top": 0, | |
| 275 | + | "Right": 0, | |
| 276 | + | "Bottom": 0, | |
| 277 | + | "Left": 0 | |
| 278 | + | }, | |
| 279 | + | "Colors": { | |
| 280 | + | "Top": { | |
| 281 | + | "R": 0, | |
| 282 | + | "G": 0, | |
| 283 | + | "B": 0, | |
| 284 | + | "A": 0 | |
| 285 | + | }, | |
| 286 | + | "Right": { | |
| 287 | + | "R": 0, | |
| 288 | + | "G": 0, | |
| 289 | + | "B": 0, | |
| 290 | + | "A": 0 | |
| 291 | + | }, | |
| 292 | + | "Bottom": { | |
| 293 | + | "R": 0, | |
| 294 | + | "G": 0, | |
| 295 | + | "B": 0, | |
| 296 | + | "A": 0 | |
| 297 | + | }, | |
| 298 | + | "Left": { | |
| 299 | + | "R": 0, | |
| 300 | + | "G": 0, | |
| 301 | + | "B": 0, | |
| 302 | + | "A": 0 | |
| 303 | + | } | |
| 304 | + | }, | |
| 305 | + | "Radii": [ | |
| 306 | + | 0, | |
| 307 | + | 0, | |
| 308 | + | 0, | |
| 309 | + | 0 | |
| 310 | + | ], | |
| 311 | + | "Text": "é", | |
| 312 | + | "Runs": [ | |
| 313 | + | { | |
| 314 | + | "Start": 0, | |
| 315 | + | "End": 1, | |
| 316 | + | "X": 26.5, | |
| 317 | + | "Y": 17.104, | |
| 318 | + | "Advance": 9.03125, | |
| 319 | + | "FaceID": "478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823/0", | |
| 320 | + | "Size": 16, | |
| 321 | + | "Glyphs": [ | |
| 322 | + | { | |
| 323 | + | "ID": 171, | |
| 324 | + | "X": 26.5, | |
| 325 | + | "Y": 17.104, | |
| 326 | + | "Advance": 9.03125, | |
| 327 | + | "Cluster": 0 | |
| 328 | + | } | |
| 329 | + | ] | |
| 330 | + | } | |
| 331 | + | ] | |
| 332 | + | }, | |
| 333 | + | { | |
| 334 | + | "Op": "preedit-underline", | |
| 335 | + | "ID": "root/p0:5:input", | |
| 336 | + | "Rect": { | |
| 337 | + | "X": 26.5, | |
| 338 | + | "Y": 20.792, | |
| 339 | + | "W": 9.03125, | |
| 340 | + | "H": 1 | |
| 341 | + | }, | |
| 342 | + | "Color": { | |
| 343 | + | "R": 0, | |
| 344 | + | "G": 0, | |
| 345 | + | "B": 0, | |
| 346 | + | "A": 1 | |
| 347 | + | }, | |
| 348 | + | "Opacity": 1, | |
| 349 | + | "Widths": { | |
| 350 | + | "Top": 0, | |
| 351 | + | "Right": 0, | |
| 352 | + | "Bottom": 0, | |
| 353 | + | "Left": 0 | |
| 354 | + | }, | |
| 355 | + | "Colors": { | |
| 356 | + | "Top": { | |
| 357 | + | "R": 0, | |
| 358 | + | "G": 0, | |
| 359 | + | "B": 0, | |
| 360 | + | "A": 0 | |
| 361 | + | }, | |
| 362 | + | "Right": { | |
| 363 | + | "R": 0, | |
| 364 | + | "G": 0, | |
| 365 | + | "B": 0, | |
| 366 | + | "A": 0 | |
| 367 | + | }, | |
| 368 | + | "Bottom": { | |
| 369 | + | "R": 0, | |
| 370 | + | "G": 0, | |
| 371 | + | "B": 0, | |
| 372 | + | "A": 0 | |
| 373 | + | }, | |
| 374 | + | "Left": { | |
| 375 | + | "R": 0, | |
| 376 | + | "G": 0, | |
| 377 | + | "B": 0, | |
| 378 | + | "A": 0 | |
| 379 | + | } | |
| 380 | + | }, | |
| 381 | + | "Radii": [ | |
| 382 | + | 0, | |
| 383 | + | 0, | |
| 384 | + | 0, | |
| 385 | + | 0 | |
| 386 | + | ] | |
| 387 | + | }, | |
| 388 | + | { | |
| 389 | + | "Op": "preedit-caret", | |
| 390 | + | "ID": "root/p0:5:input", | |
| 391 | + | "Rect": { | |
| 392 | + | "X": 35.53125, | |
| 393 | + | "Y": 0, | |
| 394 | + | "W": 1, | |
| 395 | + | "H": 21.792 | |
| 396 | + | }, | |
| 397 | + | "Color": { | |
| 398 | + | "R": 0, | |
| 399 | + | "G": 0, | |
| 400 | + | "B": 0, | |
| 401 | + | "A": 1 | |
| 402 | + | }, | |
| 403 | + | "Opacity": 1, | |
| 404 | + | "Widths": { | |
| 405 | + | "Top": 0, | |
| 406 | + | "Right": 0, | |
| 407 | + | "Bottom": 0, | |
| 408 | + | "Left": 0 | |
| 409 | + | }, | |
| 410 | + | "Colors": { | |
| 411 | + | "Top": { | |
| 412 | + | "R": 0, | |
| 413 | + | "G": 0, | |
| 414 | + | "B": 0, | |
| 415 | + | "A": 0 | |
| 416 | + | }, | |
| 417 | + | "Right": { | |
| 418 | + | "R": 0, | |
| 419 | + | "G": 0, | |
| 420 | + | "B": 0, | |
| 421 | + | "A": 0 | |
| 422 | + | }, | |
| 423 | + | "Bottom": { | |
| 424 | + | "R": 0, | |
| 425 | + | "G": 0, | |
| 426 | + | "B": 0, | |
| 427 | + | "A": 0 | |
| 428 | + | }, | |
| 429 | + | "Left": { | |
| 430 | + | "R": 0, | |
| 431 | + | "G": 0, | |
| 432 | + | "B": 0, | |
| 433 | + | "A": 0 | |
| 434 | + | } | |
| 435 | + | }, | |
| 436 | + | "Radii": [ | |
| 437 | + | 0, | |
| 438 | + | 0, | |
| 439 | + | 0, | |
| 440 | + | 0 | |
| 441 | + | ] | |
| 442 | + | }, | |
| 443 | + | { | |
| 444 | + | "Op": "clip-pop", | |
| 445 | + | "ID": "root/p0:5:input", | |
| 446 | + | "Rect": { | |
| 447 | + | "X": 0, | |
| 448 | + | "Y": 0, | |
| 449 | + | "W": 120, | |
| 450 | + | "H": 28 | |
| 451 | + | }, | |
| 452 | + | "Color": { | |
| 453 | + | "R": 0, | |
| 454 | + | "G": 0, | |
| 455 | + | "B": 0, | |
| 456 | + | "A": 0 | |
| 457 | + | }, | |
| 458 | + | "Opacity": 1, | |
| 459 | + | "Widths": { | |
| 460 | + | "Top": 0, | |
| 461 | + | "Right": 0, | |
| 462 | + | "Bottom": 0, | |
| 463 | + | "Left": 0 | |
| 464 | + | }, | |
| 465 | + | "Colors": { | |
| 466 | + | "Top": { | |
| 467 | + | "R": 0, | |
| 468 | + | "G": 0, | |
| 469 | + | "B": 0, | |
| 470 | + | "A": 0 | |
| 471 | + | }, | |
| 472 | + | "Right": { | |
| 473 | + | "R": 0, | |
| 474 | + | "G": 0, | |
| 475 | + | "B": 0, | |
| 476 | + | "A": 0 | |
| 477 | + | }, | |
| 478 | + | "Bottom": { | |
| 479 | + | "R": 0, | |
| 480 | + | "G": 0, | |
| 481 | + | "B": 0, | |
| 482 | + | "A": 0 | |
| 483 | + | }, | |
| 484 | + | "Left": { | |
| 485 | + | "R": 0, | |
| 486 | + | "G": 0, | |
| 487 | + | "B": 0, | |
| 488 | + | "A": 0 | |
| 489 | + | } | |
| 490 | + | }, | |
| 491 | + | "Radii": [ | |
| 492 | + | 0, | |
| 493 | + | 0, | |
| 494 | + | 0, | |
| 495 | + | 0 | |
| 496 | + | ] | |
| 497 | + | } | |
| 498 | + | ] | |
A testdata/control-display/input_selection-arabic.json +608 -0 collapsed to keep the page light
A testdata/control-display/input_selection-ltr.json +477 -0 collapsed to keep the page light
A testdata/control-display/radio_checked.json +257 -0 collapsed to keep the page light
A testdata/control-display/radio_disabled.json +202 -0 collapsed to keep the page light
A testdata/control-display/radio_focus-visible.json +257 -0 collapsed to keep the page light
A testdata/control-display/radio_hover.json +202 -0 collapsed to keep the page light
A testdata/control-display/radio_normal.json +202 -0 collapsed to keep the page light
A testdata/control-display/separator_hover.json +57 -0 collapsed to keep the page light
A testdata/control-display/separator_normal.json +57 -0 collapsed to keep the page light
A testdata/control-display/slider_disabled.json +312 -0 collapsed to keep the page light
A testdata/control-display/slider_focus-visible.json +367 -0 collapsed to keep the page light
A testdata/control-display/slider_hover.json +312 -0 collapsed to keep the page light
A testdata/control-display/slider_normal.json +312 -0 collapsed to keep the page light
A testdata/control-display/spacer_hover.json +57 -0 collapsed to keep the page light
A testdata/control-display/spacer_normal.json +57 -0 collapsed to keep the page light
A testdata/control-display/text_hover.json +147 -0 collapsed to keep the page light
A testdata/control-display/text_normal.json +147 -0 collapsed to keep the page light
A testdata/control-display/textarea_disabled.json +257 -0 collapsed to keep the page light
A testdata/control-display/textarea_focus-visible.json +367 -0 collapsed to keep the page light
A testdata/control-display/textarea_hover.json +257 -0 collapsed to keep the page light
A testdata/control-display/textarea_normal.json +257 -0 collapsed to keep the page light
A testdata/fonts/AdwaitaSans-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/IBMPlexSansJP-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/LICENSE-Adwaita.txt +96 -0 collapsed to keep the page light
A testdata/fonts/LICENSE-Noto.txt +201 -0 collapsed to keep the page light
A testdata/fonts/LICENSE-Plex.txt +92 -0 collapsed to keep the page light
A testdata/fonts/NotoSans-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/NotoSansArabic-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/NotoSansDevanagari-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/NotoSansSymbols2-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
A testdata/fonts/SOURCES.md +14 -0 collapsed to keep the page light
A testdata/golden/pending/1.25.png +0 -0 binary file: no text diff
binary file: no text diff
A testdata/golden/pending/1.5.png +0 -0 binary file: no text diff
binary file: no text diff
A testdata/golden/pending/1.png +0 -0 binary file: no text diff
binary file: no text diff
A testdata/golden/pending/2.png +0 -0 binary file: no text diff
binary file: no text diff