detached 5203207

feat(gui): introduce NeferGUI Wayland GUI library

commit
52032070e74c511ede8a75889a801a7bde6d1fed
author
bnema · · 2026-09-30 03:09 UTC
parent
none (root commit)
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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", &notes, 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 := &paragraph{
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
@@ -0,0 +1,74 @@
1+package nefergui
2+
3+import (
4+ "context"
5+ "errors"
6+ "github.com/bnema/nefergui/internal/edit"
7+ "testing"
8+)
9+
10+type asyncClip struct {
11+ contexts []context.Context
12+ callbacks []func([]byte, error)
13+}
14+
15+func (*asyncClip) ReadText(int) ([]byte, error) { panic("sync clipboard read on loop") }
16+func (*asyncClip) WriteText([]byte) error { return nil }
17+func (c *asyncClip) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
18+ if limit != edit.MaxClipboardBytes {
19+ panic("wrong limit")
20+ }
21+ c.contexts = append(c.contexts, ctx)
22+ c.callbacks = append(c.callbacks, deliver)
23+ return nil
24+}
25+func TestAsyncPasteFrameAndCancellation(t *testing.T) {
26+ r := newRuntime()
27+ c := &asyncClip{}
28+ r.clipboard = c
29+ name := ""
30+ var changed bool
31+ var diagnostics []string
32+ view := func(f *Frame) {
33+ changed = f.Root().Input("name", &name, Key("name")).Changed()
34+ diagnostics = f.Diagnostics()
35+ }
36+ r.Build(view)
37+ paste := func() {
38+ r.Queue(inputEvent{Target: r.Target(0), Kind: "edit-key", Text: "v", Ctrl: true})
39+ if !r.Build(view) {
40+ t.Fatal("no frame")
41+ }
42+ if changed {
43+ t.Fatal("paste changed before delivery")
44+ }
45+ }
46+ paste()
47+ paste()
48+ if c.contexts[0].Err() != context.Canceled {
49+ t.Fatal("old read not cancelled")
50+ }
51+ c.callbacks[0]([]byte("old"), nil)
52+ c.callbacks[1]([]byte("Ada"), nil)
53+ if !r.Build(view) || !changed || name != "Ada" {
54+ t.Fatalf("paste: changed=%v value=%q", changed, name)
55+ }
56+ paste()
57+ c.callbacks[2]([]byte{0xff}, nil)
58+ r.Build(view)
59+ if name != "Ada" || changed {
60+ t.Fatal("invalid UTF-8 accepted")
61+ }
62+ paste()
63+ c.callbacks[3](make([]byte, edit.MaxClipboardBytes+1), nil)
64+ r.Build(view)
65+ if name != "Ada" || changed {
66+ t.Fatal("oversize accepted")
67+ }
68+ paste()
69+ c.callbacks[4](nil, errors.New("transfer failed"))
70+ r.Build(view)
71+ if debugDiagnostics && len(diagnostics) != 1 {
72+ t.Fatalf("missing error diagnostic: %v", diagnostics)
73+ }
74+}
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 collapsed to keep the page light
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

A testdata/golden/pending/README.md +11 -0 collapsed to keep the page light
A tree.go +181 -0 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close