From 0804634dc122d909fc88db6a1cc5f551e331ee94 Mon Sep 17 00:00:00 2001 From: JustMe Date: Mon, 19 Oct 2026 21:58:54 +0800 Subject: [PATCH] Add Material 3 candidate window addon for Fcitx5 Fork of the classicui addon (Fcitx5 5.1.23) with a Material 3 look, spring based animations and a right click action menu for candidates. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 + LICENSE | 468 ++++++++ LICENSES/LGPL-2.1-or-later.txt | 468 ++++++++ NOTICE.md | 15 + README.md | 98 ++ README.zh-CN.md | 41 + config/m3ui.conf.example | 19 + kwin/install-fadingpopups-override.sh | 69 ++ scripts/build.sh | 48 + scripts/install.sh | 31 + src/CMakeLists.txt | 57 + src/buffer.cpp | 180 +++ src/buffer.h | 56 + src/classicui.conf.in.in | 18 + src/classicui.cpp | 542 +++++++++ src/classicui.h | 321 ++++++ src/classicui_public.h | 21 + src/colorhelper.cpp | 36 + src/colorhelper.h | 18 + src/common.h | 46 + src/inputwindow.cpp | 1479 +++++++++++++++++++++++++ src/inputwindow.h | 236 ++++ src/plasmathemewatchdog.cpp | 154 +++ src/plasmathemewatchdog.h | 43 + src/portalsettingmonitor.cpp | 151 +++ src/portalsettingmonitor.h | 78 ++ src/theme.cpp | 1292 +++++++++++++++++++++ src/theme.h | 469 ++++++++ src/waylandcursor.cpp | 177 +++ src/waylandcursor.h | 55 + src/waylandcursortheme.cpp | 105 ++ src/waylandcursortheme.h | 57 + src/waylandinputwindow.cpp | 253 +++++ src/waylandinputwindow.h | 53 + src/waylandpointer.cpp | 102 ++ src/waylandpointer.h | 58 + src/waylandshmwindow.cpp | 140 +++ src/waylandshmwindow.h | 44 + src/waylandui.cpp | 142 +++ src/waylandui.h | 56 + src/waylandwindow.cpp | 97 ++ src/waylandwindow.h | 105 ++ src/window.cpp | 18 + src/window.h | 33 + src/xcbinputwindow.cpp | 315 ++++++ src/xcbinputwindow.h | 45 + src/xcbmenu.cpp | 862 ++++++++++++++ src/xcbmenu.h | 149 +++ src/xcbtraywindow.cpp | 543 +++++++++ src/xcbtraywindow.h | 90 ++ src/xcbui.cpp | 834 ++++++++++++++ src/xcbui.h | 132 +++ src/xcbwindow.cpp | 217 ++++ src/xcbwindow.h | 63 ++ themes/m3ui-dark/arrow.svg | 3 + themes/m3ui-dark/highlight.svg | 3 + themes/m3ui-dark/next.svg | 3 + themes/m3ui-dark/panel.svg | 3 + themes/m3ui-dark/prev.svg | 3 + themes/m3ui-dark/radio.svg | 4 + themes/m3ui-dark/theme.conf | 117 ++ themes/m3ui-light/arrow.svg | 3 + themes/m3ui-light/highlight.svg | 3 + themes/m3ui-light/next.svg | 3 + themes/m3ui-light/panel.svg | 3 + themes/m3ui-light/prev.svg | 3 + themes/m3ui-light/radio.svg | 4 + themes/m3ui-light/theme.conf | 117 ++ 68 files changed, 11474 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 LICENSES/LGPL-2.1-or-later.txt create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 config/m3ui.conf.example create mode 100755 kwin/install-fadingpopups-override.sh create mode 100755 scripts/build.sh create mode 100755 scripts/install.sh create mode 100644 src/CMakeLists.txt create mode 100644 src/buffer.cpp create mode 100644 src/buffer.h create mode 100644 src/classicui.conf.in.in create mode 100644 src/classicui.cpp create mode 100644 src/classicui.h create mode 100644 src/classicui_public.h create mode 100644 src/colorhelper.cpp create mode 100644 src/colorhelper.h create mode 100644 src/common.h create mode 100644 src/inputwindow.cpp create mode 100644 src/inputwindow.h create mode 100644 src/plasmathemewatchdog.cpp create mode 100644 src/plasmathemewatchdog.h create mode 100644 src/portalsettingmonitor.cpp create mode 100644 src/portalsettingmonitor.h create mode 100644 src/theme.cpp create mode 100644 src/theme.h create mode 100644 src/waylandcursor.cpp create mode 100644 src/waylandcursor.h create mode 100644 src/waylandcursortheme.cpp create mode 100644 src/waylandcursortheme.h create mode 100644 src/waylandinputwindow.cpp create mode 100644 src/waylandinputwindow.h create mode 100644 src/waylandpointer.cpp create mode 100644 src/waylandpointer.h create mode 100644 src/waylandshmwindow.cpp create mode 100644 src/waylandshmwindow.h create mode 100644 src/waylandui.cpp create mode 100644 src/waylandui.h create mode 100644 src/waylandwindow.cpp create mode 100644 src/waylandwindow.h create mode 100644 src/window.cpp create mode 100644 src/window.h create mode 100644 src/xcbinputwindow.cpp create mode 100644 src/xcbinputwindow.h create mode 100644 src/xcbmenu.cpp create mode 100644 src/xcbmenu.h create mode 100644 src/xcbtraywindow.cpp create mode 100644 src/xcbtraywindow.h create mode 100644 src/xcbui.cpp create mode 100644 src/xcbui.h create mode 100644 src/xcbwindow.cpp create mode 100644 src/xcbwindow.h create mode 100644 themes/m3ui-dark/arrow.svg create mode 100644 themes/m3ui-dark/highlight.svg create mode 100644 themes/m3ui-dark/next.svg create mode 100644 themes/m3ui-dark/panel.svg create mode 100644 themes/m3ui-dark/prev.svg create mode 100644 themes/m3ui-dark/radio.svg create mode 100644 themes/m3ui-dark/theme.conf create mode 100644 themes/m3ui-light/arrow.svg create mode 100644 themes/m3ui-light/highlight.svg create mode 100644 themes/m3ui-light/next.svg create mode 100644 themes/m3ui-light/panel.svg create mode 100644 themes/m3ui-light/prev.svg create mode 100644 themes/m3ui-light/radio.svg create mode 100644 themes/m3ui-light/theme.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0dede7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +.cache/ +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04bb156 --- /dev/null +++ b/LICENSE @@ -0,0 +1,468 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. + +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the +successor of the GNU Library Public License, version 2, hence the version +number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share +and change it. By contrast, the GNU General Public Licenses are intended to +guarantee your freedom to share and change free software--to make sure the +software is free for all its users. + +This license, the Lesser General Public License, applies to some specially +designated software packages--typically libraries--of the Free Software Foundation +and other authors who decide to use it. You can use it too, but we suggest +you first think carefully about whether this license or the ordinary General +Public License is the better strategy to use in any particular case, based +on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. +Our General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for this service if you +wish); that you receive source code or can get it if you want it; that you +can change the software and use pieces of it in new free programs; and that +you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors +to deny you these rights or to ask you to surrender these rights. These restrictions +translate to certain responsibilities for you if you distribute copies of +the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for +a fee, you must give the recipients all the rights that we gave you. You must +make sure that they, too, receive or can get the source code. If you link +other code with the library, you must provide complete object files to the +recipients, so that they can relink them with the library after making changes +to the library and recompiling it. And you must show them these terms so they +know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, +and (2) we offer you this license, which gives you legal permission to copy, +distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no +warranty for the free library. Also, if the library is modified by someone +else and passed on, the recipients should know that what they have is not +the original version, so that the original author's reputation will not be +affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free +program. We wish to make sure that a company cannot effectively restrict the +users of a free program by obtaining a restrictive license from a patent holder. +Therefore, we insist that any patent license obtained for a version of the +library must be consistent with the full freedom of use specified in this +license. + +Most GNU software, including some libraries, is covered by the ordinary GNU +General Public License. This license, the GNU Lesser General Public License, +applies to certain designated libraries, and is quite different from the ordinary +General Public License. We use this license for certain libraries in order +to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared +library, the combination of the two is legally speaking a combined work, a +derivative of the original library. The ordinary General Public License therefore +permits such linking only if the entire combination fits its criteria of freedom. +The Lesser General Public License permits more lax criteria for linking other +code with the library. + +We call this license the "Lesser" General Public License because it does Less +to protect the user's freedom than the ordinary General Public License. It +also provides other free software developers Less of an advantage over competing +non-free programs. These disadvantages are the reason we use the ordinary +General Public License for many libraries. However, the Lesser license provides +advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the +widest possible use of a certain library, so that it becomes a de-facto standard. +To achieve this, non-free programs must be allowed to use the library. A more +frequent case is that a free library does the same job as widely used non-free +libraries. In this case, there is little to gain by limiting the free library +to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs +enables a greater number of people to use a large body of free software. For +example, permission to use the GNU C Library in non-free programs enables +many more people to use the whole GNU operating system, as well as its variant, +the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' +freedom, it does ensure that the user of a program that is linked with the +Library has the freedom and the wherewithal to run that program using a modified +version of the Library. + +The precise terms and conditions for copying, distribution and modification +follow. Pay close attention to the difference between a "work based on the +library" and a "work that uses the library". The former contains code derived +from the library, whereas the latter must be combined with the library in +order to run. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program +which contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Lesser General +Public License (also called "this License"). Each licensee is addressed as +"you". + +A "library" means a collection of software functions and/or data prepared +so as to be conveniently linked with application programs (which use some +of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has +been distributed under these terms. A "work based on the Library" means either +the Library or any derivative work under copyright law: that is to say, a +work containing the Library or a portion of it, either verbatim or with modifications +and/or translated straightforwardly into another language. (Hereinafter, translation +is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications +to it. For a library, complete source code means all the source code for all +modules it contains, plus any associated interface definition files, plus +the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered +by this License; they are outside its scope. The act of running a program +using the Library is not restricted, and output from such a program is covered +only if its contents constitute a work based on the Library (independent of +the use of the Library in a tool for writing it). Whether that is true depends +on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source +code as you receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice and disclaimer +of warranty; keep intact all the notices that refer to this License and to +the absence of any warranty; and distribute a copy of this License along with +the Library. + +You may charge a fee for the physical act of transferring a copy, and you +may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, +thus forming a work based on the Library, and copy and distribute such modifications +or work under the terms of Section 1 above, provided that you also meet all +of these conditions: + + a) The modified work must itself be a software library. + +b) You must cause the files modified to carry prominent notices stating that +you changed the files and the date of any change. + +c) You must cause the whole of the work to be licensed at no charge to all +third parties under the terms of this License. + +d) If a facility in the modified Library refers to a function or a table of +data to be supplied by an application program that uses the facility, other +than as an argument passed when the facility is invoked, then you must make +a good faith effort to ensure that, in the event an application does not supply +such function or table, the facility still operates, and performs whatever +part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose +that is entirely well-defined independent of the application. Therefore, Subsection +2d requires that any application-supplied function or table used by this function +must be optional: if the application does not supply it, the square root function +must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Library, and can be reasonably +considered independent and separate works in themselves, then this License, +and its terms, do not apply to those sections when you distribute them as +separate works. But when you distribute the same sections as part of a whole +which is a work based on the Library, the distribution of the whole must be +on the terms of this License, whose permissions for other licensees extend +to the entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise +the right to control the distribution of derivative or collective works based +on the Library. + +In addition, mere aggregation of another work not based on the Library with +the Library (or with a work based on the Library) on a volume of a storage +or distribution medium does not bring the other work under the scope of this +License. + +3. You may opt to apply the terms of the ordinary GNU General Public License +instead of this License to a given copy of the Library. To do this, you must +alter all the notices that refer to this License, so that they refer to the +ordinary GNU General Public License, version 2, instead of to this License. +(If a newer version than version 2 of the ordinary GNU General Public License +has appeared, then you can specify that version instead if you wish.) Do not +make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, +so the ordinary GNU General Public License applies to all subsequent copies +and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library +into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of +it, under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you accompany it with the complete corresponding +machine-readable source code, which must be distributed under the terms of +Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated +place, then offering equivalent access to copy the source code from the same +place satisfies the requirement to distribute the source code, even though +third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but +is designed to work with the Library by being compiled or linked with it, +is called a "work that uses the Library". Such a work, in isolation, is not +a derivative work of the Library, and therefore falls outside the scope of +this License. + +However, linking a "work that uses the Library" with the Library creates an +executable that is a derivative of the Library (because it contains portions +of the Library), rather than a "work that uses the library". The executable +is therefore covered by this License. Section 6 states terms for distribution +of such executables. + +When a "work that uses the Library" uses material from a header file that +is part of the Library, the object code for the work may be a derivative work +of the Library even though the source code is not. Whether this is true is +especially significant if the work can be linked without the Library, or if +the work is itself a library. The threshold for this to be true is not precisely +defined by law. + +If such an object file uses only numerical parameters, data structure layouts +and accessors, and small macros and small inline functions (ten lines or less +in length), then the use of the object file is unrestricted, regardless of +whether it is legally a derivative work. (Executables containing this object +code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute +the object code for the work under the terms of Section 6. Any executables +containing that work also fall under Section 6, whether or not they are linked +directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work +that uses the Library" with the Library to produce a work containing portions +of the Library, and distribute that work under terms of your choice, provided +that the terms permit modification of the work for the customer's own use +and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library +is used in it and that the Library and its use are covered by this License. +You must supply a copy of this License. If the work during execution displays +copyright notices, you must include the copyright notice for the Library among +them, as well as a reference directing the user to the copy of this License. +Also, you must do one of these things: + +a) Accompany the work with the complete corresponding machine-readable source +code for the Library including whatever changes were used in the work (which +must be distributed under Sections 1 and 2 above); and, if the work is an +executable linked with the Library, with the complete machine-readable "work +that uses the Library", as object code and/or source code, so that the user +can modify the Library and then relink to produce a modified executable containing +the modified Library. (It is understood that the user who changes the contents +of definitions files in the Library will not necessarily be able to recompile +the application to use the modified definitions.) + +b) Use a suitable shared library mechanism for linking with the Library. A +suitable mechanism is one that (1) uses at run time a copy of the library +already present on the user's computer system, rather than copying library +functions into the executable, and (2) will operate properly with a modified +version of the library, if the user installs one, as long as the modified +version is interface-compatible with the version that the work was made with. + +c) Accompany the work with a written offer, valid for at least three years, +to give the same user the materials specified in Subsection 6a, above, for +a charge no more than the cost of performing this distribution. + +d) If distribution of the work is made by offering access to copy from a designated +place, offer equivalent access to copy the above specified materials from +the same place. + +e) Verify that the user has already received a copy of these materials or +that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must +include any data and utility programs needed for reproducing the executable +from it. However, as a special exception, the materials to be distributed +need not include anything that is normally distributed (in either source or +binary form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component itself +accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of +other proprietary libraries that do not normally accompany the operating system. +Such a contradiction means you cannot use both them and the Library together +in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side +in a single library together with other library facilities not covered by +this License, and distribute such a combined library, provided that the separate +distribution of the work based on the Library and of the other library facilities +is otherwise permitted, and provided that you do these two things: + +a) Accompany the combined library with a copy of the same work based on the +Library, uncombined with any other library facilities. This must be distributed +under the terms of the Sections above. + +b) Give prominent notice with the combined library of the fact that part of +it is a work based on the Library, and explaining where to find the accompanying +uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library +except as expressly provided under this License. Any attempt otherwise to +copy, modify, sublicense, link with, or distribute the Library is void, and +will automatically terminate your rights under this License. However, parties +who have received copies, or rights, from you under this License will not +have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed +it. However, nothing else grants you permission to modify or distribute the +Library or its derivative works. These actions are prohibited by law if you +do not accept this License. Therefore, by modifying or distributing the Library +(or any work based on the Library), you indicate your acceptance of this License +to do so, and all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), +the recipient automatically receives a license from the original licensor +to copy, distribute, link with or modify the Library subject to these terms +and conditions. You may not impose any further restrictions on the recipients' +exercise of the rights granted herein. You are not responsible for enforcing +compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement +or for any other reason (not limited to patent issues), conditions are imposed +on you (whether by court order, agreement or otherwise) that contradict the +conditions of this License, they do not excuse you from the conditions of +this License. If you cannot distribute so as to satisfy simultaneously your +obligations under this License and any other pertinent obligations, then as +a consequence you may not distribute the Library at all. For example, if a +patent license would not permit royalty-free redistribution of the Library +by all those who receive copies directly or indirectly through you, then the +only way you could satisfy both it and this License would be to refrain entirely +from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents +or other property right claims or to contest validity of any such claims; +this section has the sole purpose of protecting the integrity of the free +software distribution system which is implemented by public license practices. +Many people have made generous contributions to the wide range of software +distributed through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing to +distribute software through any other system and a licensee cannot impose +that choice. + +This section is intended to make thoroughly clear what is believed to be a +consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain +countries either by patents or by copyrighted interfaces, the original copyright +holder who places the Library under this License may add an explicit geographical +distribution limitation excluding those countries, so that distribution is +permitted only in or among countries not thus excluded. In such case, this +License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of +the Lesser General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to address +new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies +a version number of this License which applies to it and "any later version", +you have the option of following the terms and conditions either of that version +or of any later version published by the Free Software Foundation. If the +Library does not specify a license version number, you may choose any version +ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs +whose distribution conditions are incompatible with these, write to the author +to ask for permission. For software which is copyrighted by the Free Software +Foundation, write to the Free Software Foundation; we sometimes make exceptions +for this. Our decision will be guided by the two goals of preserving the free +status of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR +THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE +STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY +"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE +THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA +OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES +OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible +use to the public, we recommend making it free software that everyone can +redistribute and change. You can do so by permitting redistribution under +these terms (or, alternatively, under the terms of the ordinary General Public +License). + +To apply these terms, attach the following notices to the library. It is safest +to attach them to the start of each source file to most effectively convey +the exclusion of warranty; and each file should have at least the "copyright" +line and a pointer to where the full notice is found. + + + +Copyright (C) + +This library is free software; you can redistribute it and/or modify it under +the terms of the GNU Lesser General Public License as published by the Free +Software Foundation; either version 2.1 of the License, or (at your option) +any later version. + +This library is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +details. + +You should have received a copy of the GNU Lesser General Public License along +with this library; if not, write to the Free Software Foundation, Inc., 51 +Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, +if any, to sign a "copyright disclaimer" for the library, if necessary. Here +is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in + +the library `Frob' (a library for tweaking knobs) written + +by James Random Hacker. + +< signature of Ty Coon > , 1 April 1990 + +Ty Coon, President of Vice + +That's all there is to it! diff --git a/LICENSES/LGPL-2.1-or-later.txt b/LICENSES/LGPL-2.1-or-later.txt new file mode 100644 index 0000000..04bb156 --- /dev/null +++ b/LICENSES/LGPL-2.1-or-later.txt @@ -0,0 +1,468 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. + +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the +successor of the GNU Library Public License, version 2, hence the version +number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share +and change it. By contrast, the GNU General Public Licenses are intended to +guarantee your freedom to share and change free software--to make sure the +software is free for all its users. + +This license, the Lesser General Public License, applies to some specially +designated software packages--typically libraries--of the Free Software Foundation +and other authors who decide to use it. You can use it too, but we suggest +you first think carefully about whether this license or the ordinary General +Public License is the better strategy to use in any particular case, based +on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. +Our General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for this service if you +wish); that you receive source code or can get it if you want it; that you +can change the software and use pieces of it in new free programs; and that +you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors +to deny you these rights or to ask you to surrender these rights. These restrictions +translate to certain responsibilities for you if you distribute copies of +the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for +a fee, you must give the recipients all the rights that we gave you. You must +make sure that they, too, receive or can get the source code. If you link +other code with the library, you must provide complete object files to the +recipients, so that they can relink them with the library after making changes +to the library and recompiling it. And you must show them these terms so they +know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, +and (2) we offer you this license, which gives you legal permission to copy, +distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no +warranty for the free library. Also, if the library is modified by someone +else and passed on, the recipients should know that what they have is not +the original version, so that the original author's reputation will not be +affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free +program. We wish to make sure that a company cannot effectively restrict the +users of a free program by obtaining a restrictive license from a patent holder. +Therefore, we insist that any patent license obtained for a version of the +library must be consistent with the full freedom of use specified in this +license. + +Most GNU software, including some libraries, is covered by the ordinary GNU +General Public License. This license, the GNU Lesser General Public License, +applies to certain designated libraries, and is quite different from the ordinary +General Public License. We use this license for certain libraries in order +to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared +library, the combination of the two is legally speaking a combined work, a +derivative of the original library. The ordinary General Public License therefore +permits such linking only if the entire combination fits its criteria of freedom. +The Lesser General Public License permits more lax criteria for linking other +code with the library. + +We call this license the "Lesser" General Public License because it does Less +to protect the user's freedom than the ordinary General Public License. It +also provides other free software developers Less of an advantage over competing +non-free programs. These disadvantages are the reason we use the ordinary +General Public License for many libraries. However, the Lesser license provides +advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the +widest possible use of a certain library, so that it becomes a de-facto standard. +To achieve this, non-free programs must be allowed to use the library. A more +frequent case is that a free library does the same job as widely used non-free +libraries. In this case, there is little to gain by limiting the free library +to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs +enables a greater number of people to use a large body of free software. For +example, permission to use the GNU C Library in non-free programs enables +many more people to use the whole GNU operating system, as well as its variant, +the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' +freedom, it does ensure that the user of a program that is linked with the +Library has the freedom and the wherewithal to run that program using a modified +version of the Library. + +The precise terms and conditions for copying, distribution and modification +follow. Pay close attention to the difference between a "work based on the +library" and a "work that uses the library". The former contains code derived +from the library, whereas the latter must be combined with the library in +order to run. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program +which contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Lesser General +Public License (also called "this License"). Each licensee is addressed as +"you". + +A "library" means a collection of software functions and/or data prepared +so as to be conveniently linked with application programs (which use some +of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has +been distributed under these terms. A "work based on the Library" means either +the Library or any derivative work under copyright law: that is to say, a +work containing the Library or a portion of it, either verbatim or with modifications +and/or translated straightforwardly into another language. (Hereinafter, translation +is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications +to it. For a library, complete source code means all the source code for all +modules it contains, plus any associated interface definition files, plus +the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered +by this License; they are outside its scope. The act of running a program +using the Library is not restricted, and output from such a program is covered +only if its contents constitute a work based on the Library (independent of +the use of the Library in a tool for writing it). Whether that is true depends +on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source +code as you receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice and disclaimer +of warranty; keep intact all the notices that refer to this License and to +the absence of any warranty; and distribute a copy of this License along with +the Library. + +You may charge a fee for the physical act of transferring a copy, and you +may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, +thus forming a work based on the Library, and copy and distribute such modifications +or work under the terms of Section 1 above, provided that you also meet all +of these conditions: + + a) The modified work must itself be a software library. + +b) You must cause the files modified to carry prominent notices stating that +you changed the files and the date of any change. + +c) You must cause the whole of the work to be licensed at no charge to all +third parties under the terms of this License. + +d) If a facility in the modified Library refers to a function or a table of +data to be supplied by an application program that uses the facility, other +than as an argument passed when the facility is invoked, then you must make +a good faith effort to ensure that, in the event an application does not supply +such function or table, the facility still operates, and performs whatever +part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose +that is entirely well-defined independent of the application. Therefore, Subsection +2d requires that any application-supplied function or table used by this function +must be optional: if the application does not supply it, the square root function +must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Library, and can be reasonably +considered independent and separate works in themselves, then this License, +and its terms, do not apply to those sections when you distribute them as +separate works. But when you distribute the same sections as part of a whole +which is a work based on the Library, the distribution of the whole must be +on the terms of this License, whose permissions for other licensees extend +to the entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise +the right to control the distribution of derivative or collective works based +on the Library. + +In addition, mere aggregation of another work not based on the Library with +the Library (or with a work based on the Library) on a volume of a storage +or distribution medium does not bring the other work under the scope of this +License. + +3. You may opt to apply the terms of the ordinary GNU General Public License +instead of this License to a given copy of the Library. To do this, you must +alter all the notices that refer to this License, so that they refer to the +ordinary GNU General Public License, version 2, instead of to this License. +(If a newer version than version 2 of the ordinary GNU General Public License +has appeared, then you can specify that version instead if you wish.) Do not +make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, +so the ordinary GNU General Public License applies to all subsequent copies +and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library +into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of +it, under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you accompany it with the complete corresponding +machine-readable source code, which must be distributed under the terms of +Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated +place, then offering equivalent access to copy the source code from the same +place satisfies the requirement to distribute the source code, even though +third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but +is designed to work with the Library by being compiled or linked with it, +is called a "work that uses the Library". Such a work, in isolation, is not +a derivative work of the Library, and therefore falls outside the scope of +this License. + +However, linking a "work that uses the Library" with the Library creates an +executable that is a derivative of the Library (because it contains portions +of the Library), rather than a "work that uses the library". The executable +is therefore covered by this License. Section 6 states terms for distribution +of such executables. + +When a "work that uses the Library" uses material from a header file that +is part of the Library, the object code for the work may be a derivative work +of the Library even though the source code is not. Whether this is true is +especially significant if the work can be linked without the Library, or if +the work is itself a library. The threshold for this to be true is not precisely +defined by law. + +If such an object file uses only numerical parameters, data structure layouts +and accessors, and small macros and small inline functions (ten lines or less +in length), then the use of the object file is unrestricted, regardless of +whether it is legally a derivative work. (Executables containing this object +code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute +the object code for the work under the terms of Section 6. Any executables +containing that work also fall under Section 6, whether or not they are linked +directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work +that uses the Library" with the Library to produce a work containing portions +of the Library, and distribute that work under terms of your choice, provided +that the terms permit modification of the work for the customer's own use +and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library +is used in it and that the Library and its use are covered by this License. +You must supply a copy of this License. If the work during execution displays +copyright notices, you must include the copyright notice for the Library among +them, as well as a reference directing the user to the copy of this License. +Also, you must do one of these things: + +a) Accompany the work with the complete corresponding machine-readable source +code for the Library including whatever changes were used in the work (which +must be distributed under Sections 1 and 2 above); and, if the work is an +executable linked with the Library, with the complete machine-readable "work +that uses the Library", as object code and/or source code, so that the user +can modify the Library and then relink to produce a modified executable containing +the modified Library. (It is understood that the user who changes the contents +of definitions files in the Library will not necessarily be able to recompile +the application to use the modified definitions.) + +b) Use a suitable shared library mechanism for linking with the Library. A +suitable mechanism is one that (1) uses at run time a copy of the library +already present on the user's computer system, rather than copying library +functions into the executable, and (2) will operate properly with a modified +version of the library, if the user installs one, as long as the modified +version is interface-compatible with the version that the work was made with. + +c) Accompany the work with a written offer, valid for at least three years, +to give the same user the materials specified in Subsection 6a, above, for +a charge no more than the cost of performing this distribution. + +d) If distribution of the work is made by offering access to copy from a designated +place, offer equivalent access to copy the above specified materials from +the same place. + +e) Verify that the user has already received a copy of these materials or +that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must +include any data and utility programs needed for reproducing the executable +from it. However, as a special exception, the materials to be distributed +need not include anything that is normally distributed (in either source or +binary form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component itself +accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of +other proprietary libraries that do not normally accompany the operating system. +Such a contradiction means you cannot use both them and the Library together +in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side +in a single library together with other library facilities not covered by +this License, and distribute such a combined library, provided that the separate +distribution of the work based on the Library and of the other library facilities +is otherwise permitted, and provided that you do these two things: + +a) Accompany the combined library with a copy of the same work based on the +Library, uncombined with any other library facilities. This must be distributed +under the terms of the Sections above. + +b) Give prominent notice with the combined library of the fact that part of +it is a work based on the Library, and explaining where to find the accompanying +uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library +except as expressly provided under this License. Any attempt otherwise to +copy, modify, sublicense, link with, or distribute the Library is void, and +will automatically terminate your rights under this License. However, parties +who have received copies, or rights, from you under this License will not +have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed +it. However, nothing else grants you permission to modify or distribute the +Library or its derivative works. These actions are prohibited by law if you +do not accept this License. Therefore, by modifying or distributing the Library +(or any work based on the Library), you indicate your acceptance of this License +to do so, and all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), +the recipient automatically receives a license from the original licensor +to copy, distribute, link with or modify the Library subject to these terms +and conditions. You may not impose any further restrictions on the recipients' +exercise of the rights granted herein. You are not responsible for enforcing +compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement +or for any other reason (not limited to patent issues), conditions are imposed +on you (whether by court order, agreement or otherwise) that contradict the +conditions of this License, they do not excuse you from the conditions of +this License. If you cannot distribute so as to satisfy simultaneously your +obligations under this License and any other pertinent obligations, then as +a consequence you may not distribute the Library at all. For example, if a +patent license would not permit royalty-free redistribution of the Library +by all those who receive copies directly or indirectly through you, then the +only way you could satisfy both it and this License would be to refrain entirely +from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents +or other property right claims or to contest validity of any such claims; +this section has the sole purpose of protecting the integrity of the free +software distribution system which is implemented by public license practices. +Many people have made generous contributions to the wide range of software +distributed through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing to +distribute software through any other system and a licensee cannot impose +that choice. + +This section is intended to make thoroughly clear what is believed to be a +consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain +countries either by patents or by copyrighted interfaces, the original copyright +holder who places the Library under this License may add an explicit geographical +distribution limitation excluding those countries, so that distribution is +permitted only in or among countries not thus excluded. In such case, this +License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of +the Lesser General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to address +new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies +a version number of this License which applies to it and "any later version", +you have the option of following the terms and conditions either of that version +or of any later version published by the Free Software Foundation. If the +Library does not specify a license version number, you may choose any version +ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs +whose distribution conditions are incompatible with these, write to the author +to ask for permission. For software which is copyrighted by the Free Software +Foundation, write to the Free Software Foundation; we sometimes make exceptions +for this. Our decision will be guided by the two goals of preserving the free +status of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR +THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE +STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY +"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE +THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA +OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES +OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible +use to the public, we recommend making it free software that everyone can +redistribute and change. You can do so by permitting redistribution under +these terms (or, alternatively, under the terms of the ordinary General Public +License). + +To apply these terms, attach the following notices to the library. It is safest +to attach them to the start of each source file to most effectively convey +the exclusion of warranty; and each file should have at least the "copyright" +line and a pointer to where the full notice is found. + + + +Copyright (C) + +This library is free software; you can redistribute it and/or modify it under +the terms of the GNU Lesser General Public License as published by the Free +Software Foundation; either version 2.1 of the License, or (at your option) +any later version. + +This library is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +details. + +You should have received a copy of the GNU Lesser General Public License along +with this library; if not, write to the Free Software Foundation, Inc., 51 +Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, +if any, to sign a "copyright disclaimer" for the library, if necessary. Here +is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in + +the library `Frob' (a library for tweaking knobs) written + +by James Random Hacker. + +< signature of Ty Coon > , 1 April 1990 + +Ty Coon, President of Vice + +That's all there is to it! diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..8b9a8b0 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,15 @@ +# Notice + +`src/` is a modified copy of the `classicui` addon of +[Fcitx5](https://github.com/fcitx/fcitx5) 5.1.23 +(`src/ui/classic`), copyright the Fcitx5 authors including CSSlayer, licensed +under LGPL-2.1-or-later. Files keep their original SPDX headers. The changes are +the renamed addon, the Material 3 painting, the animations and the candidate +action menu. + +The themes in `themes/` are derived from the layout values of a local theme and +use the same license. + +`kwin/install-fadingpopups-override.sh` patches a copy of KWin's *Fading Popups* +effect (GPL-2.0-or-later, KDE) on the user's machine. No KWin code is included +in this repository. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48b380a --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# fcitx5-m3ui + +A Material 3 styled candidate window for [Fcitx5](https://github.com/fcitx/fcitx5), +built as a user interface addon (`m3ui`) that replaces `classicui`. + +[简体中文](README.zh-CN.md) + +## Features + +- Rounded Material 3 panel with light and dark themes that follow the system. +- Spring based animations: the panel slides in with a small bounce and fades in, + the highlight pill glides between candidates. Stiffness and damping are + configurable. +- Right click a candidate to open an action menu. It shows whatever actions the + input method offers, for Pinyin that is *Forget candidate*, *Pin to top as + custom phrase* and *Delete from custom phrase*. +- Works on Wayland and X11 (Xwayland) input contexts, positioning and pointer + handling are inherited from `classicui`. +- Optional KWin helper that shortens KWin's own fade out for the candidate + window only. + +## Status + +Personal project, only tried on Fedora 44, KDE Plasma 6.7 on Wayland, NVIDIA +GPU, Fcitx5 5.1.23 with the Pinyin engine. The build scripts have not been run +on any other setup. + +The addon is a fork of `classicui` and is built inside the Fcitx5 source tree, +because Fcitx5's Wayland protocol wrappers are not installed by `fcitx5-devel`. +The Fcitx5 source version used for the build must match the installed Fcitx5. + +There is no fade out animation of its own: on Wayland the window is destroyed as +soon as the panel has nothing to show. + +## Build + +Needs the Fcitx5 development files and the dependencies of `classicui` +(cairo, pango, librsvg, gdk-pixbuf, wayland-protocols, xcb libraries). On Fedora: + +``` +sudo dnf install fcitx5-devel extra-cmake-modules cairo-devel pango-devel \ + gdk-pixbuf2-devel librsvg2-devel wayland-protocols-devel \ + plasma-wayland-protocols-devel systemd-devel libuuid-devel fmt-devel \ + expat-devel iso-codes-devel json-devel xkeyboard-config-devel \ + xcb-util-devel xcb-util-keysyms-devel xcb-util-wm-devel \ + xcb-util-image-devel xcb-imdkit-devel libxkbfile-devel libxkbcommon-x11-devel +``` + +then + +``` +scripts/build.sh # downloads Fcitx5 + yoga, overlays src/, builds m3ui +scripts/install.sh # installs into ~/.local, nothing outside $HOME +``` + +`build.sh` picks the Fcitx5 version from `pkg-config`, override it with +`FCITX_VERSION`. + +## Use + +``` +FCITX_ADDON_DIRS=$HOME/.local/m3ui/lib64/fcitx5:/usr/lib64/fcitx5 fcitx5 -r -u m3ui +``` + +`FCITX_ADDON_DIRS` is how Fcitx5 finds addons outside the system directory, it +has to be set in the environment that starts Fcitx5 if you want this on every +login. `fcitx5 -r` brings the stock interface back. + +Settings live in `~/.config/fcitx5/conf/m3ui.conf`, see +[config/m3ui.conf.example](config/m3ui.conf.example). + +| Key | Default | Meaning | +|---|---|---| +| `Animations` | True | Master switch for animations | +| `Bounce` | True | Off makes movement critically damped (no overshoot) | +| `SpatialStiffness` | 2000 | Spring stiffness for movement, higher is faster | +| `SpatialDamping` | 62 | Damping ratio in percent, lower bounces more | +| `EffectsStiffness` | 3800 | Spring stiffness for fading | +| `SlideDistance` | 22 | Slide in distance in px | +| `SlideDirection` | 0 | 0 from left, 1 from right, 2 from below | +| `Theme` / `DarkTheme` | `m3ui-light` / `m3ui-dark` | Themes in `themes/` | + +Themes are classicui themes with extra keys in `[InputPanel]`: `SurfaceColor`, +`OutlineColor`, `HighlightFillColor`, `PanelRadius`, `ItemRadius`. A transparent +`SurfaceColor` falls back to the classic image based drawing, so classic themes +still work (without animation). + +## KWin fade out (optional, KDE Plasma) + +KWin's *Fading Popups* effect fades popups out over 600 ms and the candidate +window counts as a popup. `kwin/install-fadingpopups-override.sh` copies the +effect from your system into `~/.local/share/kwin-wayland/effects/` and patches +the copy so only the window class `fcitx fcitx` is affected. The script fails +if the upstream effect source no longer matches what it patches. + +## License + +LGPL-2.1-or-later, see [NOTICE.md](NOTICE.md). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..78920ea --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,41 @@ +# fcitx5-m3ui + +给 [Fcitx5](https://github.com/fcitx/fcitx5) 用的 Material 3 风格候选框, +以界面插件(`m3ui`)的形式替换 `classicui`。 + +[English](README.md) + +## 特性 + +- Material 3 圆角面板,深色/浅色主题跟随系统。 +- 弹簧动画:候选框带轻微回弹滑入并淡入,高亮块在候选词之间滑动,刚度和阻尼可调。 +- 在候选词上右键打开动作菜单,内容取决于输入法提供的动作,拼音有 + "忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。 +- Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。 +- 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。 + +## 状态 + +个人项目,只在 Fedora 44、KDE Plasma 6.7(Wayland)、NVIDIA 显卡、 +Fcitx5 5.1.23、拼音引擎上用过,构建脚本没有在其他环境跑过。 + +本插件是 `classicui` 的分支,需要在 Fcitx5 源码树内构建(Fcitx5 的 Wayland 协议封装 +不包含在 `fcitx5-devel` 里),构建用的源码版本必须和已安装的 Fcitx5 一致。 + +没有自己的淡出动画:Wayland 下面板无内容时窗口会被立刻销毁。 + +## 构建与使用 + +依赖安装命令、`scripts/build.sh`、`scripts/install.sh` 以及启动方式见 +[English README](README.md)。启动命令: + +``` +FCITX_ADDON_DIRS=$HOME/.local/m3ui/lib64/fcitx5:/usr/lib64/fcitx5 fcitx5 -r -u m3ui +``` + +想回到原版界面运行 `fcitx5 -r`。配置在 `~/.config/fcitx5/conf/m3ui.conf`, +示例见 [config/m3ui.conf.example](config/m3ui.conf.example)。 + +## 许可证 + +LGPL-2.1-or-later,见 [NOTICE.md](NOTICE.md)。 diff --git a/config/m3ui.conf.example b/config/m3ui.conf.example new file mode 100644 index 0000000..606154b --- /dev/null +++ b/config/m3ui.conf.example @@ -0,0 +1,19 @@ +Vertical Candidate List=False +WheelForPaging=True +Font="Noto Sans CJK SC 11" +MenuFont="Noto Sans CJK SC 10" +TrayFont="Noto Sans CJK SC 10" +Theme=m3ui-light +DarkTheme=m3ui-dark +UseDarkTheme=True +UseAccentColor=False +PerScreenDPI=True +ForceWaylandDPI=0 +EnableFractionalScale=True +Animations=True +Bounce=True +SpatialStiffness=2000 +SpatialDamping=62 +EffectsStiffness=3800 +SlideDistance=22 +SlideDirection=0 diff --git a/kwin/install-fadingpopups-override.sh b/kwin/install-fadingpopups-override.sh new file mode 100755 index 0000000..73247c3 --- /dev/null +++ b/kwin/install-fadingpopups-override.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Optional. KWin's "Fading Popups" effect fades popup windows in over 150 ms +# and out over 600 ms, and the Fcitx5 candidate window counts as a popup. This +# script copies the effect from the system into your home directory and +# patches the copy so that only the Fcitx5 window ("fcitx fcitx") skips the +# fade in and uses a short fade out. All other popups are untouched. +# +# The effect is KWin's code (GPL-2.0-or-later), so it is generated from the +# copy installed on your system instead of being shipped in this repository. +set -euo pipefail + +fade_out_ms=${FADE_OUT_MS:-90} +src= +for dir in /usr/share/kwin-wayland/effects/fadingpopups /usr/share/kwin/effects/fadingpopups; do + [ -d "$dir" ] && src=$dir && break +done +[ -n "$src" ] || { echo "KWin fadingpopups effect not found" >&2; exit 1; } + +dst=${XDG_DATA_HOME:-$HOME/.local/share}/kwin-wayland/effects/fadingpopups +rm -rf "$dst" +mkdir -p "$(dirname "$dst")" +cp -r "$src" "$dst" + +python3 - "$dst/contents/code/main.js" "$fade_out_ms" <<'PY' +import sys +path, fade_out = sys.argv[1], sys.argv[2] +s = open(path).read() + +def rep(old, new): + global s + if old not in s: + sys.exit("effect source changed upstream, cannot patch: " + old[:50]) + s = s.replace(old, new, 1) + +rep("var fadingPopupsEffect = {", f'''// Added by m3ui: the Fcitx5 candidate window animates itself. +var inputMethodWindowClass = "fcitx fcitx"; +var inputMethodFadeOutMs = {fade_out}; + +function isInputMethodWindow(window) {{ + return window.windowClass == inputMethodWindowClass; +}} + +var fadingPopupsEffect = {{''') +rep(''' if (!isPopupWindow(window)) { + return; + } + if (!window.visible) { + return; + }''', ''' if (!isPopupWindow(window) || isInputMethodWindow(window)) { + return; + } + if (!window.visible) { + return; + }''') +rep(" duration: fadingPopupsEffect.fadeOutDuration,", + ''' duration: isInputMethodWindow(window) + ? animationTime(inputMethodFadeOutMs) + : fadingPopupsEffect.fadeOutDuration,''') +open(path, "w").write(s) +PY + +if command -v gdbus >/dev/null; then + gdbus call --session --dest org.kde.KWin --object-path /Effects \ + --method org.kde.kwin.Effects.unloadEffect fadingpopups >/dev/null || true + gdbus call --session --dest org.kde.KWin --object-path /Effects \ + --method org.kde.kwin.Effects.loadEffect fadingpopups >/dev/null || true +fi +echo "Installed to $dst" +echo "To undo: rm -rf '$dst' and reload the effect." diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..c5036b4 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Builds libm3ui.so against an upstream Fcitx5 source tree of the same version +# as the Fcitx5 installed on this system. The addon needs Fcitx5's internal +# Wayland protocol wrappers, which are not part of fcitx5-devel, so it is built +# inside the upstream tree. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +work=${WORK_DIR:-$root/build} +version=${FCITX_VERSION:-$(pkg-config --modversion Fcitx5Core)} +# Commit of the yoga submodule pinned by Fcitx5 (third_party/yoga). +yoga_commit=${YOGA_COMMIT:-042f5013152eb81c1552dec945b88f7b95ca350f} + +srcdir=$work/fcitx5-$version +mkdir -p "$work" + +if [ ! -d "$srcdir" ]; then + echo ">> fetching Fcitx5 $version" + curl -fsSL "https://github.com/fcitx/fcitx5/archive/refs/tags/$version.tar.gz" | + tar -xz -C "$work" +fi +if [ ! -f "$srcdir/third_party/yoga/yoga/CMakeLists.txt" ]; then + echo ">> fetching yoga $yoga_commit" + rm -rf "$srcdir/third_party/yoga" + mkdir -p "$srcdir/third_party/yoga" + curl -fsSL "https://github.com/facebook/yoga/archive/$yoga_commit.tar.gz" | + tar -xz -C "$srcdir/third_party/yoga" --strip-components=1 +fi + +echo ">> overlaying m3ui" +rm -rf "$srcdir/src/ui/m3ui" +cp -r "$root/src" "$srcdir/src/ui/m3ui" +if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then + sed -i 's/^add_subdirectory(classic)$/add_subdirectory(classic)\nadd_subdirectory(m3ui)/' \ + "$srcdir/src/ui/CMakeLists.txt" +fi + +echo ">> configuring" +cmake -S "$srcdir" -B "$work/cmake" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PREFIX:-$HOME/.local/m3ui}" \ + -DENABLE_X11=On -DENABLE_WAYLAND=On \ + -DENABLE_TEST=Off -DENABLE_TESTING_ADDONS=Off -DENABLE_ENCHANT=Off \ + -DENABLE_DOC=Off -DENABLE_XDGAUTOSTART=Off -DENABLE_EMOJI=Off + +echo ">> building" +ninja -C "$work/cmake" m3ui src/ui/m3ui/m3ui.conf +echo ">> done, run scripts/install.sh" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..4b73029 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Installs the addon, the themes and an example config into your home +# directory. Nothing outside $HOME is touched and the stock classicui stays +# available. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +work=${WORK_DIR:-$root/build} +prefix=${PREFIX:-$HOME/.local/m3ui} +data=${XDG_DATA_HOME:-$HOME/.local/share}/fcitx5 +config=${XDG_CONFIG_HOME:-$HOME/.config}/fcitx5 + +# Going through cmake's install step strips the build tree rpath, otherwise the +# addon would load a second copy of libFcitx5Core from the build directory. +cmake -DCMAKE_INSTALL_PREFIX="$prefix" -P "$work/cmake/src/ui/m3ui/cmake_install.cmake" + +mkdir -p "$data/addon" "$data/themes" "$config/conf" +cp "$prefix/share/fcitx5/addon/m3ui.conf" "$data/addon/m3ui.conf" +cp -r "$root"/themes/* "$data/themes/" +if [ ! -e "$config/conf/m3ui.conf" ]; then + cp "$root/config/m3ui.conf.example" "$config/conf/m3ui.conf" +fi + +addondir=$(dirname "$(find "$prefix" -name libm3ui.so | head -1)") +cat </dev/null || echo /usr/lib64/fcitx5) fcitx5 -r -u m3ui + +Go back to the stock interface with: fcitx5 -r +MSG diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..9958e85 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,57 @@ +if (ENABLE_X11 OR WAYLAND_FOUND) + +set(CLASSICUI_SRCS) +set(CLASSICUI_LIBS) +if (WAYLAND_FOUND) + set(CLASSICUI_SRCS ${CLASSICUI_SRCS} + waylandui.cpp waylandwindow.cpp waylandshmwindow.cpp + waylandpointer.cpp buffer.cpp waylandinputwindow.cpp + waylandcursor.cpp waylandcursortheme.cpp) + set(CLASSICUI_LIBS ${CLASSICUI_LIBS} + Fcitx5::Module::Wayland Fcitx5::Module::WaylandIM Wayland::Client Fcitx5::Wayland::Core + Wayland::Cursor + Fcitx5::Wayland::InputMethod Fcitx5::Wayland::InputMethodV2 + Fcitx5::Wayland::ExtBackgroundEffect + Fcitx5::Wayland::FractionalScale Fcitx5::Wayland::Viewporter + Fcitx5::Wayland::CursorShape) +else() +endif() + +if (ENABLE_X11) + set(CLASSICUI_SRCS ${CLASSICUI_SRCS} + xcbui.cpp xcbwindow.cpp xcbtraywindow.cpp xcbinputwindow.cpp xcbmenu.cpp) + set(CLASSICUI_LIBS ${CLASSICUI_LIBS} PkgConfig::CairoXCB Fcitx5::Module::XCB + XCB::AUX XCB::ICCCM XCB::XINERAMA XCB::RANDR XCB::EWMH) +endif() + +if (ENABLE_DBUS) + set(CLASSICUI_SRCS ${CLASSICUI_SRCS} portalsettingmonitor.cpp) + set(CLASSICUI_LIBS ${CLASSICUI_LIBS} Fcitx5::Module::DBus) +endif() + +add_fcitx5_addon(m3ui + classicui.cpp window.cpp theme.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS} + ) + +if (ENABLE_X11) +target_compile_definitions(m3ui PRIVATE -DFCITX_XCB_EWMH) +endif() + +target_link_libraries(m3ui + Fcitx5::Core + PkgConfig::Cairo PkgConfig::Pango + PkgConfig::GdkPixbuf PkgConfig::Rsvg PkgConfig::GioUnix + Fcitx5::Module::NotificationItem + ${YOGA_TARGET} + ${CLASSICUI_LIBS} + ) + +install(TARGETS m3ui DESTINATION "${FCITX_INSTALL_ADDONDIR}") +configure_file(classicui.conf.in.in m3ui.conf.in @ONLY) +fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/m3ui.conf.in m3ui.conf) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/m3ui.conf" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon" + COMPONENT config) + +endif() + +fcitx5_export_module(M3UI TARGET m3ui BUILD_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}" HEADERS classicui_public.h) diff --git a/src/buffer.cpp b/src/buffer.cpp new file mode 100644 index 0000000..fbcce19 --- /dev/null +++ b/src/buffer.cpp @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2017-2020 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "buffer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/environ.h" +#include "fcitx-utils/fs.h" +#include "fcitx-utils/stringutils.h" +#include "fcitx-utils/unixfd.h" +#include "wl_buffer.h" +#include "wl_callback.h" +#include "wl_shm.h" +#include "wl_shm_pool.h" +#include "wl_surface.h" + +namespace fcitx::wayland { + +#define RETRY_ON_EINTR(...) \ + do { \ + __VA_ARGS__; \ + } while (ret < 0 && errno == EINTR) + +UnixFD openShm() { + int ret; + // We support multiple different methods, memfd / shm_open on BSD / + // O_TMPFILE. While linux has shm_open, it doesn't have SHM_ANON extension + // like bsd. Since memfd is introduced in 3.17, which is already quite old, + // don't bother to add another implementation for Linux, since we need to + // fallback to file as a final resolution. +#if defined(__NR_memfd_create) + do { + static bool hasMemfdCreate = true; + if (!hasMemfdCreate) { + break; + } + int options = MFD_CLOEXEC; +#if defined(MFD_ALLOW_SEALING) + options |= MFD_ALLOW_SEALING; +#endif + RETRY_ON_EINTR( + ret = syscall(__NR_memfd_create, "fcitx-wayland-shm", options)); + + if (ret < 0 && errno == ENOSYS) { + hasMemfdCreate = false; + break; + } +#if defined(F_ADD_SEALS) && defined(F_SEAL_SHRINK) + if (ret >= 0) { + fcntl(ret, F_ADD_SEALS, F_SEAL_SHRINK); + return UnixFD::own(ret); + } +#endif + } while (0); +#endif + + // Try shm_open(SHM_ANON) on BSD. +#if defined(__FreeBSD__) + RETRY_ON_EINTR( + ret = shm_open(SHM_ANON, O_CREAT | O_EXCL | O_RDWR | O_CLOEXEC, 0600)); + if (ret >= 0) { + return UnixFD::own(ret); + } +#endif + + auto path = getEnvironment("XDG_RUNTIME_DIR"); + if (!path) { + throw std::runtime_error("XDG_RUNTIME_DIR is not set"); + } + + // Prefer O_TMPFILE over mkstemp. + // The value check is to ensure it has the right value. + // It is said that some old glibc may have problem. +#if defined(O_TMPFILE) && (O_TMPFILE & O_DIRECTORY) == O_DIRECTORY + do { + std::string pathStr = fs::cleanPath(*path); + RETRY_ON_EINTR(ret = + open(pathStr.data(), + O_TMPFILE | O_CLOEXEC | O_EXCL | O_RDWR, 0600)); + if (errno == EOPNOTSUPP || errno == EISDIR) { + break; + } + if (ret >= 0) { + return UnixFD::own(ret); + } + return {}; + } while (0); +#endif + + auto filename = std::filesystem::path(*path) / "fcitx-wayland-shm-XXXXXX"; + std::string v = filename.string(); + RETRY_ON_EINTR(ret = mkstemp(v.data())); + + if (ret >= 0) { + unlink(v.data()); + if (int flags = fcntl(ret, F_GETFD); flags != -1) { + fcntl(ret, F_SETFD, flags | FD_CLOEXEC); + } + return UnixFD::own(ret); + } + + return {}; +} + +Buffer::Buffer(WlShm *shm, uint32_t width, uint32_t height, + wl_shm_format format) + : width_(width), height_(height) { + uint32_t stride = width * 4; + uint32_t alloc = stride * height; + UnixFD fd = openShm(); + if (!fd.isValid()) { + return; + } + + if (posix_fallocate(fd.fd(), 0, alloc) != 0) { + return; + } + uint8_t *data = (uint8_t *)mmap(nullptr, alloc, PROT_READ | PROT_WRITE, + MAP_SHARED, fd.fd(), 0); + + if (data == static_cast(MAP_FAILED)) { + return; + } + + data_ = data; + dataSize_ = alloc; + + pool_.reset(shm->createPool(fd.fd(), alloc)); + buffer_.reset(pool_->createBuffer(0, width, height, stride, format)); + buffer_->release().connect([this]() { busy_ = false; }); + + surface_.reset(cairo_image_surface_create_for_data( + data, CAIRO_FORMAT_ARGB32, width, height, stride)); +} + +Buffer::~Buffer() { + callback_.reset(); + surface_.reset(); + buffer_.reset(); + pool_.reset(); + if (data_) { + munmap(data_, dataSize_); + } +} + +bool Buffer::attachToSurface(WlSurface *surface, int scale) { + if (busy_) { + return false; + } + busy_ = true; + callback_.reset(surface->frame()); + callback_->done().connect([this](uint32_t) { + // CLASSICUI_DEBUG() << "Shm window rendered. " << this; + // Need to ensure buffer won't be deleted. + busy_ = false; + rendered_(); + callback_.reset(); + }); + + surface->attach(buffer(), 0, 0); + surface->setBufferScale(scale); + surface->damage(0, 0, width_, height_); + return true; +} + +} // namespace fcitx::wayland diff --git a/src/buffer.h b/src/buffer.h new file mode 100644 index 0000000..28ee1a3 --- /dev/null +++ b/src/buffer.h @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_WAYLAND_CORE_BUFFER_H_ +#define _FCITX_WAYLAND_CORE_BUFFER_H_ + +#include +#include +#include +#include +#include +#include "fcitx-utils/misc.h" +#include "fcitx-utils/signals.h" + +namespace fcitx { +namespace wayland { + +class WlShm; +class WlShmPool; +class WlBuffer; +class WlCallback; +class WlSurface; + +class Buffer { +public: + Buffer(WlShm *shm, uint32_t width, uint32_t height, wl_shm_format format); + ~Buffer(); + + bool busy() const { return busy_; } + uint32_t width() const { return width_; } + uint32_t height() const { return height_; } + cairo_surface_t *cairoSurface() const { return surface_.get(); } + WlBuffer *buffer() const { return buffer_.get(); } + + bool attachToSurface(WlSurface *surface, int scale); + + auto &rendered() { return rendered_; } + +private: + uint8_t *data_ = nullptr; + size_t dataSize_ = 0; + Signal rendered_; + std::unique_ptr pool_; + std::unique_ptr buffer_; + std::unique_ptr callback_; + UniqueCPtr surface_; + bool busy_ = false; + uint32_t width_, height_; +}; +} // namespace wayland +} // namespace fcitx + +#endif // _FCITX_WAYLAND_CORE_BUFFER_H_ diff --git a/src/classicui.conf.in.in b/src/classicui.conf.in.in new file mode 100644 index 0000000..4044339 --- /dev/null +++ b/src/classicui.conf.in.in @@ -0,0 +1,18 @@ +[Addon] +Name=Material 3 User Interface +Type=@FCITX_ADDON_TYPE@ +Library=libm3ui +Category=UI +Version=@PROJECT_VERSION@ +OnDemand=True +Configurable=True + +[Addon/Dependencies] +0=core:@PROJECT_VERSION@ + +[Addon/OptionalDependencies] +0=xcb +1=wayland +2=notificationitem +3=dbus +4=waylandim diff --git a/src/classicui.cpp b/src/classicui.cpp new file mode 100644 index 0000000..dedbc5c --- /dev/null +++ b/src/classicui.cpp @@ -0,0 +1,542 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "classicui.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-config/iniparser.h" +#include "fcitx-config/rawconfig.h" +#include "fcitx-utils/color.h" +#include "fcitx-utils/dbus/message_details.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/fs.h" +#include "fcitx-utils/i18n.h" +#include "fcitx-utils/log.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/misc_p.h" +#include "fcitx-utils/standardpaths.h" +#include "fcitx/addonfactory.h" +#include "fcitx/addoninstance.h" +#include "fcitx/event.h" +#include "fcitx/inputcontext.h" +#include "fcitx/inputcontextmanager.h" +#include "fcitx/instance.h" +#include "fcitx/userinterface.h" +#include "common.h" +#include "notificationitem_public.h" +#include "plasmathemewatchdog.h" +#include "theme.h" +#ifdef ENABLE_X11 +#include +#include "xcb_public.h" +#include "xcbui.h" +#endif +#ifdef WAYLAND_FOUND +#include "wayland_public.h" +#include "waylandui.h" +#endif +#ifdef ENABLE_DBUS +#include "fcitx-utils/dbus/variant.h" +#include "dbus_public.h" +#include "portalsettingmonitor.h" +#endif + +namespace fcitx::m3ui { + +FCITX_DEFINE_LOG_CATEGORY(m3ui_logcategory, "m3ui"); + +using AccentColorDBusType = FCITX_STRING_TO_DBUS_TYPE("(ddd)"); + +M3UI::M3UI(Instance *instance) : instance_(instance) { +#ifdef ENABLE_DBUS + if (auto *dbusAddon = dbus()) { + dbus::VariantTypeRegistry::defaultRegistry() + .registerType(); + settingMonitor_ = std::make_unique( + *dbusAddon->call()); + } +#endif + + reloadConfig(); + +#ifdef ENABLE_X11 + if (auto *xcbAddon = xcb()) { + xcbCreatedCallback_ = + xcbAddon->call( + [this](const std::string &name, xcb_connection_t *conn, + int screen, FocusGroup *) { + auto xcbui = + std::make_unique(this, name, conn, screen); + uis_[xcbui->name()] = std::move(xcbui); + CLASSICUI_INFO() + << "Created classicui for x11 display:" << name; + }); + xcbClosedCallback_ = + xcbAddon->call( + [this](const std::string &name, xcb_connection_t *) { + uis_.erase("x11:" + name); + }); + } +#endif + +#ifdef WAYLAND_FOUND + if (auto *waylandAddon = wayland()) { + waylandCreatedCallback_ = + waylandAddon->call( + [this](const std::string &name, wl_display *display, + FocusGroup *) { + try { + auto waylandui = + std::make_unique(this, name, display); + uis_[waylandui->name()] = std::move(waylandui); + CLASSICUI_INFO() + << "Created classicui for wayland display:" << name; + } catch (const std::runtime_error &) { + } + }); + waylandClosedCallback_ = + waylandAddon->call( + [this](const std::string &name, wl_display *) { + uis_.erase("wayland:" + name); + }); + } +#endif + deferedReloadTheme_ = + instance_->eventLoop().addDeferEvent([this](EventSource *) { + reloadTheme(); + return true; + }); + deferedReloadTheme_->setEnabled(false); + + // Since kimpanel may call classicui + persistentEventHandlers_.emplace_back(instance_->watchEvent( + EventType::FocusGroupFocusChanged, EventWatcherPhase::Default, + [this](Event &event) { + auto &focusEvent = + static_cast(event); + if (!focusEvent.newFocus()) { + if (auto *ui = uiForDisplay(focusEvent.group()->display())) { + ui->update(UserInterfaceComponent::InputPanel, nullptr); + } + } + })); + persistentEventHandlers_.emplace_back(instance_->watchEvent( + EventType::UIChanged, EventWatcherPhase::Default, [this](Event &) { + if (instance_->currentUI() == "kimpanel") { + deferedReloadTheme_->setOneShot(); + } else if (instance_->currentUI() == "m3ui") { + deferedReloadTheme_->setOneShot(); + } + })); +} + +M3UI::~M3UI() {} + +void M3UI::reloadConfig() { + readAsIni(config_, "conf/m3ui.conf"); + reloadTheme(); +} + +void M3UI::reloadTheme() { +#ifdef ENABLE_DBUS + auto parseMessage = [this](const dbus::Variant &variant) { + if (variant.signature() == "u") { + auto color = variant.dataAs(); + auto oldIsDark = isDark_; + isDark_ = (color == 1); + if (oldIsDark != isDark_) { + CLASSICUI_DEBUG() << "XDG Portal AppearanceChanged " + "isDark" + << isDark_; + deferedReloadTheme_->setOneShot(); + } + } + }; + + if (dbus()) { + if (*config_.useDarkTheme) { + if (!darkModeEntry_ && settingMonitor_) { + darkModeEntry_ = settingMonitor_->watch( + "org.freedesktop.appearance", "color-scheme", parseMessage); + } + } else { + darkModeEntry_.reset(); + } + } +#endif + + bool hasPlasmaTheme = + instance_->currentUI() == "kimpanel" || + (*config_.theme == PlasmaThemeName) || + (*config_.useDarkTheme && *config_.themeDark == PlasmaThemeName); + + if (hasPlasmaTheme) { + if (!plasmaThemeWatchdog_ && PlasmaThemeWatchdog::isAvailable()) { + try { + plasmaThemeWatchdog_ = std::make_unique( + &instance_->eventLoop(), [this]() { + CLASSICUI_DEBUG() << "Reload plasma theme"; + reloadTheme(); + }); + } catch (...) { + } + } + } else { + plasmaThemeWatchdog_.reset(); + } + + std::string_view themeName = (*config_.useDarkTheme && isDark_) + ? *config_.themeDark + : *config_.theme; + + if (instance_->currentUI() == "kimpanel") { + if (plasmaThemeWatchdog_) { + themeName = "plasma"; + } + } + + theme_.load(themeName); + +#ifdef ENABLE_DBUS + auto parseAccentColor = [this](const dbus::Variant &variant) { + if (variant.signature() == "(ddd)") { + auto dbuscolor = variant.dataAs(); + Color color; + color.setAlphaF(1); + color.setRedF(std::get<0>(dbuscolor)); + color.setGreenF(std::get<1>(dbuscolor)); + color.setBlueF(std::get<2>(dbuscolor)); + if (!accentColor_ || *accentColor_ != color) { + accentColor_ = color; + CLASSICUI_DEBUG() << "XDG Portal AccentColor changed " + "color: " + << accentColor_; + deferedReloadTheme_->setOneShot(); + } + } + }; + + if (dbus()) { + if (*config_.useAccentColor) { + if (!accentColorEntry_ && settingMonitor_) { + accentColorEntry_ = + settingMonitor_->watch("org.freedesktop.appearance", + "accent-color", parseAccentColor); + } + } else { + accentColorEntry_.reset(); + accentColor_ = std::nullopt; + } + } +#endif + + theme_.populateColor(accentColor_); +} + +void M3UI::suspend() { + suspended_ = true; + for (auto &p : uis_) { + p.second->suspend(); + } + + if (auto *sni = notificationitem()) { + sni->call(); + } + eventHandlers_.clear(); +} + +const Configuration *M3UI::getConfig() const { + auto themeDirs = StandardPaths::global().locate( + StandardPathsType::PkgData, "themes", + [](const std::filesystem::path &path) { + std::error_code ec; + return std::filesystem::is_directory(path, ec); + }); + std::map> themes; + for (const auto &[themeName, _] : themeDirs) { + auto file = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / themeName / "theme.conf", + Theme::isSystemThemeName(themeName.string()) + ? StandardPathsMode::System + : StandardPathsMode::Default); + if (file.fd() < 0) { + continue; + } + RawConfig config; + readFromIni(config, file.fd()); + + ThemeConfig themeConfig; + themeConfig.load(config); + if (!themeConfig.metadata.value() + .name.value() + .defaultString() + .empty()) { + themes[themeName] = + themeConfig.metadata.value().name.value().match(); + } + } + + bool plasmaTheme = false; + if (PlasmaThemeWatchdog::isAvailable()) { + if (auto iter = themes.find(PlasmaThemeName); iter != themes.end()) { + themes.erase(iter); + } + themes.emplace(PlasmaThemeName, _("KDE Plasma (Experimental)")); + plasmaTheme = true; + } + + config_.theme.annotation().setThemes({themes.begin(), themes.end()}, + plasmaTheme); + config_.themeDark.annotation().setThemes({themes.begin(), themes.end()}, + plasmaTheme); + return &config_; +} + +UIInterface *M3UI::uiForEvent(Event &event) { + if (suspended_) { + return nullptr; + } + + if (!event.isInputContextEvent()) { + return nullptr; + } + + auto &icEvent = static_cast(event); + return uiForInputContext(icEvent.inputContext()); +} + +UIInterface *M3UI::uiForInputContext(InputContext *inputContext) { + if (suspended_ || !inputContext) { + return nullptr; + } + if (!inputContext->hasFocus()) { + return nullptr; + } + return uiForDisplay(inputContext->display()); +} + +UIInterface *M3UI::uiForDisplay(const std::string &display) { + auto iter = uis_.find(display); + if (iter == uis_.end()) { + return nullptr; + } + return iter->second.get(); +} + +void M3UI::resume() { + CLASSICUI_DEBUG() << "Resume M3UI"; + suspended_ = false; + for (auto &p : uis_) { + p.second->resume(); + } + + if (auto *sni = notificationitem()) { + if (!sniHandler_) { + sniHandler_ = + sni->call([this](bool enable) { + for (auto &p : uis_) { + p.second->setEnableTray(!enable); + } + }); + } + sni->call(); + // Delay 1 sec to enable xembed tray icon. + deferedEnableTray_ = instance_->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 1000000, 0, + [this](EventSource *, uint64_t) { + // If we are now suspended, just return. + if (suspended()) { + return true; + } + if (auto *sni = notificationitem()) { + auto registered = + sni->call(); + for (auto &p : uis_) { + p.second->setEnableTray(!registered); + } + } + deferedEnableTray_.reset(); + return true; + }); + } else { + for (auto &p : uis_) { + p.second->setEnableTray(true); + } + } + + eventHandlers_.emplace_back(instance_->watchEvent( + EventType::InputContextCursorRectChanged, EventWatcherPhase::Default, + [this](Event &event) { + if (auto *ui = uiForEvent(event)) { + auto &icEvent = static_cast(event); + ui->updateCursor(icEvent.inputContext()); + } + })); + eventHandlers_.emplace_back(instance_->watchEvent( + EventType::InputContextFocusIn, EventWatcherPhase::Default, + [this](Event &event) { + if (auto *ui = uiForEvent(event)) { + auto &icEvent = static_cast(event); + ui->updateCursor(icEvent.inputContext()); + ui->updateCurrentInputMethod(icEvent.inputContext()); + } + })); + eventHandlers_.emplace_back(instance_->watchEvent( + EventType::InputContextSwitchInputMethod, EventWatcherPhase::Default, + [this](Event &event) { + if (auto *ui = uiForEvent(event)) { + auto &icEvent = static_cast(event); + ui->updateCurrentInputMethod(icEvent.inputContext()); + } + })); + eventHandlers_.emplace_back(instance_->watchEvent( + EventType::InputMethodGroupChanged, EventWatcherPhase::Default, + [this](Event &) { + instance_->inputContextManager().foreachFocused( + [this](InputContext *ic) { + if (auto *ui = uiForInputContext(ic)) { + ui->updateCurrentInputMethod(ic); + } + return true; + }); + })); + eventHandlers_.emplace_back(instance_->watchEvent( + EventType::FocusGroupFocusChanged, EventWatcherPhase::Default, + [this](Event &event) { + auto &focusEvent = + static_cast(event); + if (!focusEvent.newFocus()) { + if (auto *ui = uiForDisplay(focusEvent.group()->display())) { + ui->update(UserInterfaceComponent::InputPanel, nullptr); + } + } + })); +} + +void M3UI::update(UserInterfaceComponent component, + InputContext *inputContext) { + UIInterface *ui = nullptr; + if (inputContext->display().starts_with("wayland:") && + !inputContext->frontendName().starts_with("wayland")) { + // If display is wayland, but frontend is not, then we can only do X11 + // for now, though position is wrong. We don't know which is xwayland + // unfortunately, hopefully main display is X wayland. + // The position will be wrong anyway. +#ifdef ENABLE_X11 + if (auto *xcbAddon = xcb()) { + auto mainX11Display = xcbAddon->call(); + if (!mainX11Display.empty()) { + if (auto *uiPtr = findValue(uis_, "x11:" + mainX11Display)) { + ui = uiPtr->get(); + } + } + } +#endif + } else { + if (auto *uiPtr = findValue(uis_, inputContext->display())) { + ui = uiPtr->get(); + } + } + CLASSICUI_DEBUG() << "Update component: " << static_cast(component) + << " for IC program:" << inputContext->program() + << " frontend:" << inputContext->frontendName() + << " display:" << inputContext->display() + << " ui:" << (ui ? ui->name() : "(not available)"); + + if (ui) { + ui->update(component, inputContext); + if (component == UserInterfaceComponent::StatusArea) { + ui->updateCurrentInputMethod(inputContext); + } + } +} + +class M3UIFactory : public AddonFactory { +public: + AddonInstance *create(AddonManager *manager) override { + return new M3UI(manager->instance()); + } +}; + +const fcitx::Configuration * +M3UI::getSubConfig(const std::string &path) const { + if (!path.starts_with("theme/")) { + return nullptr; + } + + auto name = path.substr(6); + if (!ThemeNameConstrain{}.check(name)) { + return nullptr; + } + + subconfigTheme_.load(name); + return &subconfigTheme_; +} + +void M3UI::setSubConfig(const std::string &path, + const fcitx::RawConfig &config) { + if (!path.starts_with("theme/")) { + return; + } + auto name = path.substr(6); + if (!ThemeNameConstrain{}.check(name)) { + return; + } + + if (Theme::isSystemThemeName(name)) { + return; + } + + auto &theme = name == theme_.name() ? theme_ : subconfigTheme_; + if (&theme == &subconfigTheme_) { + // Fill the system value. + getSubConfig(path); + } else { + deferedReloadTheme_->setOneShot(); + } + theme.load(name, config); + safeSaveAsIni(theme, StandardPathsType::PkgData, + std::filesystem::path("themes") / name / "theme.conf"); +} + +std::vector M3UI::labelIcon(const std::string &label, + unsigned int size) { + std::vector data; + size_t stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, size); + data.resize(stride * size); + UniqueCPtr image; + image.reset(cairo_image_surface_create_for_data( + data.data(), CAIRO_FORMAT_ARGB32, size, size, stride)); + ThemeImage::drawTextIcon(image.get(), label, size, config_); + image.reset(); + return data; +} + +bool M3UI::preferTextIcon() const { return *config_.preferTextIcon; } + +bool M3UI::showLayoutNameInIcon() const { + return *config_.showLayoutNameInIcon; +} + +} // namespace fcitx::m3ui + +FCITX_ADDON_FACTORY_V2(m3ui, fcitx::m3ui::M3UIFactory); diff --git a/src/classicui.h b/src/classicui.h new file mode 100644 index 0000000..20352ce --- /dev/null +++ b/src/classicui.h @@ -0,0 +1,321 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_CLASSICUI_H_ +#define _FCITX_UI_CLASSIC_CLASSICUI_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-config/configuration.h" +#include "fcitx-config/iniparser.h" +#include "fcitx-config/option.h" +#include "fcitx-config/rawconfig.h" +#include "fcitx-utils/color.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/handlertable.h" +#include "fcitx-utils/i18n.h" +#include "fcitx-utils/stringutils.h" +#include "fcitx/addoninstance.h" +#include "fcitx/addonmanager.h" +#include "fcitx/event.h" +#include "fcitx/instance.h" +#include "fcitx/userinterface.h" +#include "classicui_public.h" +#include "config.h" +#include "plasmathemewatchdog.h" +#include "portalsettingmonitor.h" +#include "theme.h" +#ifdef ENABLE_X11 +#include "xcb_public.h" +#endif +#ifdef WAYLAND_FOUND +#include "wayland_public.h" +#endif +#ifdef ENABLE_DBUS +#endif + +namespace fcitx::m3ui { + +inline constexpr std::string_view PlasmaThemeName = "plasma"; + +class UIInterface { +public: + UIInterface(std::string name) : name_(std::move(name)) {} + + const std::string &name() const { return name_; } + + virtual ~UIInterface() {} + virtual void update(UserInterfaceComponent component, + InputContext *inputContext) = 0; + virtual void updateCursor(InputContext * /*unused*/) {} + virtual void updateCurrentInputMethod(InputContext * /*unused*/) {} + virtual void suspend() = 0; + virtual void resume() {} + virtual void setEnableTray(bool) = 0; + +private: + const std::string name_; +}; + +struct ThemeNameConstrain { + using Type = std::string; + + bool check(const std::string &value) const { + const auto path = std::filesystem::path(value); + return !value.empty() && path.is_relative() && + !path.has_parent_path() && path.filename() == path && + path.filename() != "." && path.filename() != ".."; + } + + void dumpDescription(RawConfig & /*unused*/) const {} +}; + +struct ThemeAnnotation : public EnumAnnotation { + void setThemes(std::vector> themes, + bool plasmaTheme) { + themes_ = std::move(themes); + plasmaTheme_ = plasmaTheme; + } + void dumpDescription(RawConfig &config) const { + EnumAnnotation::dumpDescription(config); + config.setValueByPath("LaunchSubConfig", "True"); + for (size_t i = 0; i < themes_.size(); i++) { + config.setValueByPath("Enum/" + std::to_string(i), + themes_[i].first); + config.setValueByPath("EnumI18n/" + std::to_string(i), + themes_[i].second); + if (themes_[i].first != PlasmaThemeName && + !Theme::isSystemThemeName(themes_[i].first)) { + config.setValueByPath( + "SubConfigPath/" + std::to_string(i), + stringutils::concat("fcitx://config/addon/m3ui/theme/", + themes_[i].first)); + } else { + config.setValueByPath("SubConfigPath/" + std::to_string(i), ""); + } + } + } + +private: + std::vector> themes_; + bool plasmaTheme_ = false; +}; + +struct MenuFontAnnotation : private FontAnnotation, private ToolTipAnnotation { + MenuFontAnnotation() + : ToolTipAnnotation( + _("This is only effective when the tray icon is xembed.")) {} + + bool skipDescription() { return false; } + bool skipSave() { return false; } + void dumpDescription(RawConfig &config) { + FontAnnotation::dumpDescription(config); + ToolTipAnnotation::dumpDescription(config); + } +}; + +FCITX_CONFIGURATION( + ClassicUIConfig, + Option verticalCandidateList{this, "Vertical Candidate List", + _("Vertical Candidate List"), false}; + Option useWheelForPaging{ + this, "WheelForPaging", _("Use mouse wheel to go to prev or next page"), + true}; + + OptionWithAnnotation font{ + this, "Font", _("Font"), "Sans 10"}; + OptionWithAnnotation menuFont{ + this, "MenuFont", _("Menu Font"), "Sans 10"}; + OptionWithAnnotation trayFont{ + this, "TrayFont", _("Tray Font"), "Sans Bold 10"}; + Option trayBorderColor{this, "TrayOutlineColor", + _("Tray Label Outline Color"), + Color("#000000ff")}; + Option trayTextColor{this, "TrayTextColor", + _("Tray Label Text Color"), Color("#ffffffff")}; + Option preferTextIcon{this, "PreferTextIcon", _("Prefer Text Icon"), + false}; + OptionWithAnnotation showLayoutNameInIcon{ + this, + "ShowLayoutNameInIcon", + _("Show Layout Name In Icon"), + true, + {}, + {}, + {_("Show layout name in icon if there is more than one active layout. " + "If prefer text icon is set to true, this option will be " + "ignored.")}}; + OptionWithAnnotation + useInputMethodLanguageToDisplayText{ + this, + "UseInputMethodLanguageToDisplayText", + _("Use input method language to display text"), + true, + {}, + {}, + {_("For example, display character with Chinese variant when using " + "Pinyin and Japanese variant when using Anthy. The font " + "configuration needs to support this to use this feature.")}}; + Option, + ThemeAnnotation> + theme{this, "Theme", _("Theme"), "default"}; + Option, + ThemeAnnotation> + themeDark{this, "DarkTheme", _("Dark Theme"), "default-dark"}; + Option useDarkTheme{this, "UseDarkTheme", + _("Follow system light/dark color scheme"), + false}; + Option useAccentColor{ + this, "UseAccentColor", + _("Follow system accent color if it is supported by theme and desktop"), + true}; + OptionWithAnnotation perScreenDPI{ + this, + "PerScreenDPI", + _("Use Per Screen DPI on X11"), + false, + {}, + {}, + {_("This option will be always disabled on XWayland.")}}; + Option, ToolTipAnnotation> + forceWaylandDPI{ + this, + "ForceWaylandDPI", + _("Force font DPI on Wayland"), + 0, + IntConstrain(0), + {}, + {_("Normally Wayland uses 96 as font DPI in combination with the " + "screen scale factor. This option allows you to override the " + "font DPI. If the value is 0, it means this option is " + "disabled.")}}; + + Option animations{this, "Animations", _("Enable animations"), true}; + Option bounce{this, "Bounce", + _("Bouncy overshoot (off: critically damped)"), true}; + Option spatialStiffness{ + this, "SpatialStiffness", + _("Spring stiffness for movement, higher is faster"), 2000, + IntConstrain(50, 20000)}; + Option spatialDamping{ + this, "SpatialDamping", + _("Spring damping ratio for movement in percent, lower bounces more"), + 62, IntConstrain(20, 100)}; + Option effectsStiffness{ + this, "EffectsStiffness", + _("Spring stiffness for fading, higher is faster"), 3800, + IntConstrain(50, 20000)}; + Option slideDistance{ + this, "SlideDistance", _("Slide-in distance (px)"), 22, + IntConstrain(0, 200)}; + Option slideDirection{ + this, "SlideDirection", + _("Slide-in direction (0: from left, 1: from right, 2: from below)"), + 0, IntConstrain(0, 2)}; + + OptionWithAnnotation fractionalScale{ + this, + "EnableFractionalScale", + _("Enable fractional scale under Wayland"), + true, + {}, + {}, + {_("This option require support from wayland compositor.")}};); + +class M3UI final : public UserInterface { +public: + M3UI(Instance *instance); + ~M3UI(); + + FCITX_ADDON_DEPENDENCY_LOADER(xcb, instance_->addonManager()); + FCITX_ADDON_DEPENDENCY_LOADER(wayland, instance_->addonManager()); + FCITX_ADDON_DEPENDENCY_LOADER(waylandim, instance_->addonManager()); + FCITX_ADDON_DEPENDENCY_LOADER(dbus, instance_->addonManager()); + Instance *instance() const { return instance_; } + const Configuration *getConfig() const override; + void setConfig(const RawConfig &config) override { + config_.load(config, true); + safeSaveAsIni(config_, "conf/m3ui.conf"); + reloadTheme(); + } + const Configuration *getSubConfig(const std::string &path) const override; + void setSubConfig(const std::string &path, + const RawConfig &config) override; + auto &config() const { return config_; } + Theme &theme() { return theme_; } + void suspend() override; + void resume() override; + bool suspended() const { return suspended_; } + bool available() override { return true; } + void update(UserInterfaceComponent component, + InputContext *inputContext) override; + void reloadConfig() override; + + std::vector labelIcon(const std::string &label, + unsigned int size); + bool preferTextIcon() const; + bool showLayoutNameInIcon() const; + +private: + FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager()); + FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon); + FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon); + FCITX_ADDON_EXPORT_FUNCTION(M3UI, showLayoutNameInIcon); + + UIInterface *uiForEvent(Event &event); + UIInterface *uiForInputContext(InputContext *inputContext); + UIInterface *uiForDisplay(const std::string &display); + void reloadTheme(); + +#ifdef ENABLE_X11 + std::unique_ptr> + xcbCreatedCallback_; + std::unique_ptr> xcbClosedCallback_; +#endif + +#ifdef WAYLAND_FOUND + std::unique_ptr> + waylandCreatedCallback_; + std::unique_ptr> + waylandClosedCallback_; +#endif + + std::unique_ptr deferedReloadTheme_; +#ifdef ENABLE_DBUS + std::unique_ptr settingMonitor_; + std::unique_ptr darkModeEntry_; + std::unique_ptr accentColorEntry_; +#endif + + std::vector>> + eventHandlers_; + std::vector>> + persistentEventHandlers_; + std::unique_ptr sniHandler_; + + std::unordered_map> uis_; + + Instance *instance_; + ClassicUIConfig config_; + Theme theme_; + mutable Theme subconfigTheme_; + bool suspended_ = true; + bool isDark_ = false; + std::optional accentColor_ = std::nullopt; + + std::unique_ptr deferedEnableTray_; + std::unique_ptr plasmaThemeWatchdog_; +}; +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_CLASSICUI_H_ diff --git a/src/classicui_public.h b/src/classicui_public.h new file mode 100644 index 0000000..5b7dcb4 --- /dev/null +++ b/src/classicui_public.h @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2021~2021 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX5_UI_CLASSIC_CLASSICUI_PUBLIC_H_ +#define _FCITX5_UI_CLASSIC_CLASSICUI_PUBLIC_H_ + +#include +#include +#include +#include + +FCITX_ADDON_DECLARE_FUNCTION( + M3UI, labelIcon, + std::vector(const std::string &label, unsigned int size)); +FCITX_ADDON_DECLARE_FUNCTION(M3UI, preferTextIcon, bool()); +FCITX_ADDON_DECLARE_FUNCTION(M3UI, showLayoutNameInIcon, bool()); + +#endif // _FCITX5_UI_CLASSIC_CLASSICUI_PUBLIC_H_ diff --git a/src/colorhelper.cpp b/src/colorhelper.cpp new file mode 100644 index 0000000..fdb2cd2 --- /dev/null +++ b/src/colorhelper.cpp @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +#include "colorhelper.h" +#include +#include +#include "fcitx-utils/color.h" + +namespace fcitx::m3ui { + +inline float normalize(float v) { + return (v < 1.0 ? (v > 0.0 ? v : 0.0) : 1.0); +} + +float gamma(float n) { return std::pow(normalize(n), 2.2); } + +float lumag(float r, float g, float b) { + return r * 0.2126 + g * 0.7152 + b * 0.0722; +} + +float luma(const Color &c) { + return lumag(gamma(c.redF()), gamma(c.greenF()), gamma(c.blueF())); +} + +std::pair accentForeground(const Color &accent) { + // light bg + if (luma(accent) > 0.5) { + return {Color(0, 0, 0), Color(51, 51, 51)}; + } + return {Color(255, 255, 255), Color(204, 204, 204)}; +} + +} // namespace fcitx::m3ui diff --git a/src/colorhelper.h b/src/colorhelper.h new file mode 100644 index 0000000..9c9a1ba --- /dev/null +++ b/src/colorhelper.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_COLORHELPER_H_ +#define _FCITX_UI_CLASSIC_COLORHELPER_H_ + +#include +#include "fcitx-utils/color.h" + +namespace fcitx::m3ui { + +std::pair accentForeground(const Color &accent); +} + +#endif diff --git a/src/common.h b/src/common.h new file mode 100644 index 0000000..61e7b0d --- /dev/null +++ b/src/common.h @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2020-2020 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX5_UI_CLASSIC_COMMON_H_ +#define _FCITX5_UI_CLASSIC_COMMON_H_ + +#include +#include +#include +#include "fcitx-utils/log.h" +#include "fcitx-utils/misc.h" + +namespace fcitx::m3ui { + +template +using GObjectUniquePtr = UniqueCPtr; + +template +float absolute(YGNodeRef node) { + float offset = 0.0F; + while (node) { + offset += Getter(node); + node = YGNodeGetParent(node); + } + return offset; +} + +template +float absolute(const std::unique_ptr &node) { + return absolute(node.get()); +} + +FCITX_DECLARE_LOG_CATEGORY(m3ui_logcategory); +#define CLASSICUI_DEBUG() \ + FCITX_LOGC(::fcitx::m3ui::m3ui_logcategory, Debug) +#define CLASSICUI_ERROR() \ + FCITX_LOGC(::fcitx::m3ui::m3ui_logcategory, Error) +#define CLASSICUI_INFO() \ + FCITX_LOGC(::fcitx::m3ui::m3ui_logcategory, Info) + +} // namespace fcitx::m3ui + +#endif // _FCITX5_UI_CLASSIC_COMMON_H_ diff --git a/src/inputwindow.cpp b/src/inputwindow.cpp new file mode 100644 index 0000000..0caca7f --- /dev/null +++ b/src/inputwindow.cpp @@ -0,0 +1,1479 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "inputwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/color.h" +#include "fcitx-utils/event.h" +#include "fcitx-utils/log.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/textformatflags.h" +#include "fcitx/candidateaction.h" +#include "fcitx/candidatelist.h" +#include "fcitx/inputmethodentry.h" +#include "fcitx/inputpanel.h" +#include "fcitx/instance.h" +#include "fcitx/misc_p.h" +#include "fcitx/text.h" +#include "fcitx/userinterface.h" +#include "classicui.h" +#include "common.h" +#include "theme.h" + +namespace fcitx::m3ui { + +namespace { + +auto newPangoLayout(PangoContext *context) { + GObjectUniquePtr ptr(pango_layout_new(context)); + pango_layout_set_single_paragraph_mode(ptr.get(), false); + return ptr; +} + +void prepareLayout(cairo_t *cr, PangoLayout *layout) { + const PangoMatrix *matrix; + + matrix = pango_context_get_matrix(pango_layout_get_context(layout)); + + if (matrix) { + cairo_matrix_t cairo_matrix; + + cairo_matrix_init(&cairo_matrix, matrix->xx, matrix->yx, matrix->xy, + matrix->yy, matrix->x0, matrix->y0); + + cairo_transform(cr, &cairo_matrix); + } +} + +void renderLayout(cairo_t *cr, PangoLayout *layout, int x, int y) { + auto *context = pango_layout_get_context(layout); + const auto *fontDescription = pango_layout_get_font_description(layout); + if (!fontDescription) { + fontDescription = pango_context_get_font_description(context); + } + auto *metrics = pango_context_get_metrics( + context, fontDescription, pango_context_get_language(context)); + auto ascent = pango_font_metrics_get_ascent(metrics); + pango_font_metrics_unref(metrics); + auto baseline = pango_layout_get_baseline(layout); + auto yOffset = PANGO_PIXELS(ascent - baseline); + cairo_save(cr); + + // Ensure the text are not painting on half pixel. + cairo_move_to(cr, x, y + yOffset); + double dx; + double dy; + double odx; + double ody; + cairo_get_current_point(cr, &dx, &dy); + // Save old user value + odx = dx; + ody = dy; + // Convert to device and round. + cairo_user_to_device(cr, &dx, &dy); + double ndx = std::round(dx); + double ndy = std::round(dy); + // Convert back to user and calculate delta. + cairo_device_to_user(cr, &ndx, &ndy); + cairo_move_to(cr, x + ndx - odx, y + yOffset + ndy - ody); + + prepareLayout(cr, layout); + pango_cairo_show_layout(cr, layout); + + cairo_restore(cr); +} + +// Position of a unit mass spring released from 0 towards 1, the way Material 3 +// Expressive describes motion with a stiffness and a damping ratio. +double springValue(double seconds, double stiffness, double damping) { + const double w = std::sqrt(stiffness); + if (damping >= 1.0) { + return 1.0 - (1.0 + w * seconds) * std::exp(-w * seconds); + } + const double wd = w * std::sqrt(1.0 - damping * damping); + const double decay = std::exp(-damping * w * seconds); + return 1.0 - decay * (std::cos(wd * seconds) + + (damping * w / wd) * std::sin(wd * seconds)); +} + +// Time after which the spring stays within half a percent of its target. +double springSettleSeconds(double stiffness, double damping) { + const double w = std::sqrt(stiffness); + return damping >= 1.0 ? 7.5 / w : 5.3 / (damping * w); +} + +double millisecondsSince(std::chrono::steady_clock::time_point start, + std::chrono::steady_clock::time_point now) { + return std::chrono::duration(now - start).count(); +} + +void roundedRectPath(cairo_t *cr, double x, double y, double w, double h, + double r) { + r = std::max(0.0, std::min({r, w / 2, h / 2})); + cairo_new_sub_path(cr); + cairo_arc(cr, x + w - r, y + r, r, -M_PI / 2, 0); + cairo_arc(cr, x + w - r, y + h - r, r, 0, M_PI / 2); + cairo_arc(cr, x + r, y + h - r, r, M_PI / 2, M_PI); + cairo_arc(cr, x + r, y + r, r, M_PI, 3 * M_PI / 2); + cairo_close_path(cr); +} + +void fillRoundedRect(cairo_t *cr, const Color &color, double x, double y, + double w, double h, double r) { + cairo_save(cr); + roundedRectPath(cr, x, y, w, h, r); + cairoSetSourceColor(cr, color); + cairo_fill(cr); + cairo_restore(cr); +} + +} // namespace + +int MultilineLayout::width() const { + int width = 0; + for (const auto &layout : lines_) { + int w; + int h; + pango_layout_get_pixel_size(layout.get(), &w, &h); + width = std::max(width, w); + } + return width; +} + +void MultilineLayout::render(cairo_t *cr, int x, int y, bool highlight) { + int lineHeight = fontHeight(); + for (size_t i = 0; i < lines_.size(); i++) { + if (highlight) { + pango_layout_set_attributes(lines_[i].get(), + highlightAttrLists_[i].get()); + } else { + pango_layout_set_attributes(lines_[i].get(), attrLists_[i].get()); + } + renderLayout(cr, lines_[i].get(), x, y); + y += lineHeight; + } +} + +InputWindow::InputWindow(M3UI *parent) : parent_(parent) { + fontMap_.reset(pango_cairo_font_map_new()); + // Although documentation says it is 96 by default, try not rely on this + // behavior. + fontMapDefaultDPI_ = pango_cairo_font_map_get_resolution( + PANGO_CAIRO_FONT_MAP(fontMap_.get())); + context_.reset(pango_font_map_create_context(fontMap_.get())); + upperLayout_ = newPangoLayout(context_.get()); + lowerLayout_ = newPangoLayout(context_.get()); + + rootNode_.reset(YGNodeNew()); + mainNode_.reset(YGNodeNew()); + + upperNode_.reset(YGNodeNew()); + upperTextNode_.reset(YGNodeNew()); + + lowerNode_.reset(YGNodeNew()); + auxDownNode_.reset(YGNodeNew()); + auxDownTextNode_.reset(YGNodeNew()); + candidatesNode_.reset(YGNodeNew()); + + buttonNode_.reset(YGNodeNew()); + + YGNodeInsertChild(rootNode_.get(), mainNode_.get(), 0); + YGNodeInsertChild(rootNode_.get(), buttonNode_.get(), 1); + YGNodeStyleSetFlexDirection(rootNode_.get(), YGFlexDirectionRow); + + YGNodeInsertChild(mainNode_.get(), upperNode_.get(), 0); + YGNodeInsertChild(mainNode_.get(), lowerNode_.get(), 1); + YGNodeStyleSetFlexDirection(mainNode_.get(), YGFlexDirectionColumn); + + YGNodeInsertChild(upperNode_.get(), upperTextNode_.get(), 0); + + YGNodeInsertChild(lowerNode_.get(), auxDownNode_.get(), 0); + YGNodeInsertChild(auxDownNode_.get(), auxDownTextNode_.get(), 0); + + YGNodeInsertChild(lowerNode_.get(), candidatesNode_.get(), 1); +} + +void InputWindow::insertAttr(PangoAttrList *attrList, TextFormatFlags format, + int start, int end, bool highlight, + TextType type) const { + if (format & TextFormatFlag::Underline) { + auto *attr = pango_attr_underline_new(PANGO_UNDERLINE_SINGLE); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + } + if (format & TextFormatFlag::Italic) { + auto *attr = pango_attr_style_new(PANGO_STYLE_ITALIC); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + } + if (format & TextFormatFlag::Strike) { + auto *attr = pango_attr_strikethrough_new(true); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + } + if (format & TextFormatFlag::Bold) { + auto *attr = pango_attr_weight_new(PANGO_WEIGHT_BOLD); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + } + Color color; + if (format & TextFormatFlag::HighLight) { + color = parent_->theme().inputPanelHighlightText(); + } else { + Color table[2][3] = { + {parent_->theme().inputPanelCandidateLabelText(), + parent_->theme().inputPanelText(), + parent_->theme().inputPanelCandidateCommentText()}, + {parent_->theme().inputPanelHighlightCandidateLabelText(), + parent_->theme().inputPanelHighlightCandidateText(), + parent_->theme().inputPanelHighlightCandidateCommentText()}}; + color = table[highlight][static_cast(type)]; + } + const auto scale = std::numeric_limits::max(); + auto *attr = pango_attr_foreground_new( + color.redF() * scale, color.greenF() * scale, color.blueF() * scale); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + + if (color.alpha() != 255) { + auto *alphaAttr = + pango_attr_foreground_alpha_new(color.alphaF() * scale); + alphaAttr->start_index = start; + alphaAttr->end_index = end; + pango_attr_list_insert(attrList, alphaAttr); + } + + auto background = parent_->theme().inputPanelHighlight(); + if (format.test(TextFormatFlag::HighLight) && background.alpha() > 0) { + attr = pango_attr_background_new(background.redF() * scale, + background.greenF() * scale, + background.blueF() * scale); + attr->start_index = start; + attr->end_index = end; + pango_attr_list_insert(attrList, attr); + + if (background.alpha() != 255) { + auto *alphaAttr = + pango_attr_background_alpha_new(background.alphaF() * scale); + alphaAttr->start_index = start; + alphaAttr->end_index = end; + pango_attr_list_insert(attrList, alphaAttr); + } + } +} + +void InputWindow::appendText(std::string &s, PangoAttrList *attrList, + PangoAttrList *highlightAttrList, const Text &text, + TextType type) { + for (size_t i = 0, e = text.size(); i < e; i++) { + auto start = s.size(); + s.append(text.stringAt(i)); + auto end = s.size(); + if (start == end) { + continue; + } + const auto format = text.formatAt(i); + insertAttr(attrList, format, start, end, false, type); + if (highlightAttrList) { + insertAttr(highlightAttrList, format, start, end, true, type); + } + } +} + +void InputWindow::resizeCandidates(size_t n) { + while (candidateLayouts_.size() < n) { + candidateLayouts_.emplace_back(); + } + + nCandidates_ = n; +} + +void InputWindow::setTextToMultilineLayout(InputContext *inputContext, + MultilineLayout &layout, + const Text &text, TextType type) { + auto lines = text.splitByLine(); + layout.lines_.clear(); + layout.attrLists_.clear(); + layout.highlightAttrLists_.clear(); + + for (const auto &line : lines) { + layout.lines_.emplace_back(pango_layout_new(context_.get())); + layout.attrLists_.emplace_back(); + layout.highlightAttrLists_.emplace_back(); + setTextToLayout(inputContext, layout.lines_.back().get(), + &layout.attrLists_.back(), + &layout.highlightAttrLists_.back(), {line}, type); + } +} + +void InputWindow::setTextToLayout( + InputContext *inputContext, PangoLayout *layout, + PangoAttrListUniquePtr *attrList, PangoAttrListUniquePtr *highlightAttrList, + std::initializer_list> texts, + TextType type) { + auto *newAttrList = pango_attr_list_new(); + if (attrList) { + // PangoAttrList does not have "clear()". So when we set new text, + // we need to create a new one and get rid of old one. + // We keep a ref to the attrList. + attrList->reset(pango_attr_list_ref(newAttrList)); + } + PangoAttrList *newHighlightAttrList = nullptr; + if (highlightAttrList) { + newHighlightAttrList = pango_attr_list_new(); + highlightAttrList->reset(newHighlightAttrList); + } + std::string line; + for (const auto &text : texts) { + appendText(line, newAttrList, newHighlightAttrList, text, type); + } + + const auto *entry = parent_->instance()->inputMethodEntry(inputContext); + if (*parent_->config().useInputMethodLanguageToDisplayText && entry && + !entry->languageCode().empty()) { + if (auto *language = + pango_language_from_string(entry->languageCode().c_str())) { + if (newAttrList) { + auto *attr = pango_attr_language_new(language); + attr->start_index = 0; + attr->end_index = line.size(); + pango_attr_list_insert(newAttrList, attr); + } + if (newHighlightAttrList) { + auto *attr = pango_attr_language_new(language); + attr->start_index = 0; + attr->end_index = line.size(); + pango_attr_list_insert(newHighlightAttrList, attr); + } + } + } + + pango_layout_set_text(layout, line.c_str(), line.size()); + pango_layout_set_attributes(layout, newAttrList); + pango_attr_list_unref(newAttrList); +} + +std::pair InputWindow::update(InputContext *inputContext) { + hoverIndex_ = -1; + const bool wasVisible = visible_; + if ((parent_->suspended() && + parent_->instance()->currentUI() != "kimpanel") || + !inputContext) { + visible_ = false; + closeMenu(); + return {0, 0}; + } + // | aux up | preedit + // | aux down + // | 1 candidate | 2 ... + // or + // | aux up | preedit + // | aux down + // | candidate 1 + // | candidate 2 + // | candidate 3 + auto *instance = parent_->instance(); + auto &inputPanel = inputContext->inputPanel(); + inputContext_ = inputContext->watch(); + + cursor_ = -1; + Text preedit; + Text auxUp; + Text auxDown; + std::shared_ptr candidateList; + if (!inputPanel.empty()) { + preedit = instance->outputFilter(inputContext, inputPanel.preedit()); + auxUp = instance->outputFilter(inputContext, inputPanel.auxUp()); + auxDown = instance->outputFilter(inputContext, inputPanel.auxDown()); + candidateList = inputPanel.candidateList(); + } else if (!inputPanel.overlayMessage().empty()) { + auxUp = inputPanel.overlayMessage(); + } + pango_layout_set_single_paragraph_mode(upperLayout_.get(), true); + setTextToLayout(inputContext, upperLayout_.get(), nullptr, nullptr, + {auxUp, preedit}); + if (preedit.cursor() >= 0 && + static_cast(preedit.cursor()) <= preedit.textLength()) { + cursor_ = preedit.cursor() + auxUp.toString().size(); + } + + setTextToLayout(inputContext, lowerLayout_.get(), nullptr, nullptr, + {auxDown}); + + std::string signature; + if (candidateList) { + // Count non-placeholder candidates. + int count = 0; + + for (int i = 0, e = candidateList->size(); i < e; i++) { + const auto &candidate = candidateList->candidate(i); + if (candidate.isPlaceHolder()) { + continue; + } + count++; + } + resizeCandidates(count); + + candidateIndex_ = -1; + int localIndex = 0; + for (int i = 0, e = candidateList->size(); i < e; i++) { + const auto &candidate = candidateList->candidate(i); + // Skip placeholder. + if (candidate.isPlaceHolder()) { + continue; + } + + if (i == candidateList->cursorIndex()) { + candidateIndex_ = localIndex; + } + + Text labelText = candidate.hasCustomLabel() + ? candidate.customLabel() + : candidateList->label(i); + + labelText = instance->outputFilter(inputContext, labelText); + setTextToMultilineLayout(inputContext, + candidateLayouts_[localIndex].label, + labelText, TextType::Label); + auto candidateText = + instance->outputFilter(inputContext, candidate.text()); + signature += candidateText.toString(); + signature += '\x1f'; + setTextToMultilineLayout(inputContext, + candidateLayouts_[localIndex].text, + candidateText, TextType::Regular); + auto realCommentText = + instance->outputFilter(inputContext, candidate.comment()); + Text commentText; + if (!realCommentText.empty()) { + if (candidate.spaceBetweenComment()) { + // Still need some extra space before comment, let's just + // add a space and see. + commentText = Text(" "); + } + commentText.append(realCommentText); + } + setTextToMultilineLayout(inputContext, + candidateLayouts_[localIndex].comment, + commentText, TextType::Comment); + localIndex++; + } + + layoutHint_ = candidateList->layoutHint(); + if (auto *pageable = candidateList->toPageable()) { + hasPrev_ = pageable->hasPrev(); + hasNext_ = pageable->hasNext(); + } else { + hasPrev_ = false; + hasNext_ = false; + } + } else { + nCandidates_ = 0; + candidateIndex_ = -1; + hasPrev_ = false; + hasNext_ = false; + } + + visible_ = nCandidates_ || + pango_layout_get_character_count(upperLayout_.get()) || + pango_layout_get_character_count(lowerLayout_.get()); + if (signature != candidateSignature_) { + candidateSignature_ = std::move(signature); + candidateListChanged_ = true; + closeMenu(); + } + if (!visible_) { + closeMenu(); + } else if (!wasVisible) { + startShowAnimation(); + } + int width = 0; + int height = 0; + if (visible_) { + std::tie(width, height) = sizeHint(); + if (width <= 0 || height <= 0) { + width = height = 0; + visible_ = false; + } + } + return {width, height}; +} + +std::pair InputWindow::sizeHint() { + auto &theme = parent_->theme(); + auto *fontDesc = + pango_font_description_from_string(parent_->config().font->c_str()); + pango_context_set_font_description(context_.get(), fontDesc); + pango_layout_context_changed(upperLayout_.get()); + pango_layout_context_changed(lowerLayout_.get()); + auto originalSize = pango_font_description_get_size(fontDesc); + auto labelSize = + originalSize * (*theme.inputPanel->labelTextSizeFactor / 100.0); + auto commentSize = + originalSize * (*theme.inputPanel->commentTextSizeFactor / 100.0); + for (size_t i = 0; i < nCandidates_; i++) { + + candidateLayouts_[i].label.contextChanged(); + candidateLayouts_[i].text.contextChanged(); + candidateLayouts_[i].comment.contextChanged(); + if (originalSize > 0) { + // For candidate, we use a smaller font size. + pango_font_description_set_size(fontDesc, labelSize); + candidateLayouts_[i].label.setFontDescription(fontDesc); + + // For candidate, we use a smaller font size. + pango_font_description_set_size(fontDesc, commentSize); + candidateLayouts_[i].comment.setFontDescription(fontDesc); + } else { + candidateLayouts_[i].label.setFontDescription(nullptr); + candidateLayouts_[i].comment.setFontDescription(nullptr); + } + } + pango_font_description_free(fontDesc); + + // Update yoga layout to calculate dimensions + updateYogaLayout(); + + // Get dimensions from yoga layout + float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get()); + float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get()); + + menuTop_ = yogaHeight; + if (menuOpen_) { + auto [menuWidth, menuHeight] = menuSize(); + yogaWidth = std::max(yogaWidth, menuWidth); + yogaHeight += menuHeight; + } + + auto width = static_cast(yogaWidth); + auto height = static_cast(yogaHeight); + return {width, height}; +} + +void InputWindow::paint(cairo_t *cr, double width, double height) { + auto &theme = parent_->theme(); + cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); + cairo_paint(cr); + cairo_set_operator(cr, CAIRO_OPERATOR_OVER); + const bool material = materialStyle(); + const auto now = Clock::now(); + bool needFrame = false; + const double spatialK = *parent_->config().spatialStiffness; + const double spatialZ = + *parent_->config().bounce + ? *parent_->config().spatialDamping / 100.0 + : 1.0; + const double effectsK = *parent_->config().effectsStiffness; + double showAlpha = 1.0; + if (material) { + double showOffset = 0.0; + if (showAnimating_) { + const double seconds = millisecondsSince(showStart_, now) / 1000.0; + const double settle = std::max( + springSettleSeconds(spatialK, spatialZ), + springSettleSeconds(effectsK, 1.0)); + if (seconds >= settle) { + showAnimating_ = false; + } else { + // Fading never overshoots, only movement bounces. + showAlpha = std::clamp(springValue(seconds, effectsK, 1.0), + 0.0, 1.0); + showOffset = (1.0 - springValue(seconds, spatialK, spatialZ)) * + *parent_->config().slideDistance; + needFrame = true; + } + } + // Everything is drawn into a group so it can be faded as a whole. + cairo_push_group(cr); + cairo_save(cr); + switch (*parent_->config().slideDirection) { + case 1: + cairo_translate(cr, showOffset, 0); + break; + case 2: + cairo_translate(cr, 0, showOffset); + break; + case 0: + default: + cairo_translate(cr, -showOffset, 0); + break; + } + cairo_save(cr); + roundedRectPath(cr, 0.5, 0.5, width - 1, height - 1, + *theme.inputPanel->panelRadius); + cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor); + cairo_fill_preserve(cr); + if (theme.inputPanel->outlineColor->alpha() > 0) { + cairoSetSourceColor(cr, *theme.inputPanel->outlineColor); + cairo_set_line_width(cr, 1); + cairo_stroke(cr); + } else { + cairo_new_path(cr); + } + cairo_restore(cr); + } else { + theme.paint(cr, *theme.inputPanel->background, 0, 0, width, height, + /*alpha=*/1.0); + } + const auto &margin = *theme.inputPanel->contentMargin; + const auto &textMargin = *theme.inputPanel->textMargin; + cairo_set_operator(cr, CAIRO_OPERATOR_OVER); + cairo_save(cr); + + cairoSetSourceColor(cr, theme.inputPanelText()); + // CLASSICUI_DEBUG() << theme.inputPanel->normalColor->toString(); + auto *metrics = pango_context_get_metrics( + context_.get(), pango_context_get_font_description(context_.get()), + pango_context_get_language(context_.get())); + auto fontHeight = pango_font_metrics_get_ascent(metrics) + + pango_font_metrics_get_descent(metrics); + pango_font_metrics_unref(metrics); + fontHeight = PANGO_PIXELS(fontHeight); + + // Use yoga-based positioning for upper layout + if (pango_layout_get_character_count(upperLayout_.get())) { + float upperLeft = absolute(upperTextNode_); + float upperTop = absolute(upperTextNode_); + + renderLayout(cr, upperLayout_.get(), upperLeft, upperTop); + + PangoRectangle pos; + if (cursor_ >= 0) { + pango_layout_get_cursor_pos(upperLayout_.get(), cursor_, &pos, + nullptr); + + cairo_save(cr); + cairo_set_line_width(cr, 2); + auto offsetX = pango_units_to_double(pos.x); + cairo_move_to(cr, upperLeft + offsetX + 1, upperTop); + cairo_line_to(cr, upperLeft + offsetX + 1, upperTop + fontHeight); + cairo_stroke(cr); + cairo_restore(cr); + } + } + + // Use yoga-based positioning for lower layout + if (pango_layout_get_character_count(lowerLayout_.get())) { + float lowerLeft = absolute(auxDownTextNode_); + float lowerTop = absolute(auxDownTextNode_); + + renderLayout(cr, lowerLayout_.get(), lowerLeft, lowerTop); + } + + if (material) { + const int hi = highlight(); + if (hi >= 0 && static_cast(hi) < nCandidates_) { + const auto &hm = *theme.inputPanel->highlight->margin; + bool vertical = parent_->config().verticalCandidateList.value(); + if (layoutHint_ == CandidateLayoutHint::Vertical) { + vertical = true; + } else if (layoutHint_ == CandidateLayoutHint::Horizontal) { + vertical = false; + } + const auto &inner = candidateNodes_[hi].inner; + double w = YGNodeLayoutGetWidth(inner.get()); + if (*theme.inputPanel->fullWidthHighlight && vertical) { + w = width - *margin.marginLeft - *margin.marginRight - + *textMargin.marginRight - *textMargin.marginLeft; + } + RectF target; + target.x = absolute(inner) - *hm.marginLeft; + target.y = absolute(inner) - *hm.marginTop; + target.w = w + *hm.marginLeft + *hm.marginRight; + target.h = YGNodeLayoutGetHeight(inner.get()) + *hm.marginTop + + *hm.marginBottom; + if (candidateListChanged_ || !highlightValid_ || + !animationsEnabled()) { + highlightCurrent_ = highlightFrom_ = highlightTarget_ = target; + highlightValid_ = true; + highlightAnimating_ = false; + } else if (!(target == highlightTarget_)) { + highlightFrom_ = highlightCurrent_; + highlightTarget_ = target; + highlightStart_ = now; + highlightAnimating_ = true; + } + if (highlightAnimating_) { + const double seconds = + millisecondsSince(highlightStart_, now) / 1000.0; + if (seconds >= springSettleSeconds(spatialK, spatialZ)) { + highlightCurrent_ = highlightTarget_; + highlightAnimating_ = false; + } else { + const double e = springValue(seconds, spatialK, spatialZ); + auto lerp = [e](double a, double b) { + return a + (b - a) * e; + }; + highlightCurrent_ = { + lerp(highlightFrom_.x, highlightTarget_.x), + lerp(highlightFrom_.y, highlightTarget_.y), + lerp(highlightFrom_.w, highlightTarget_.w), + lerp(highlightFrom_.h, highlightTarget_.h)}; + needFrame = true; + } + } + fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, + highlightCurrent_.x, highlightCurrent_.y, + highlightCurrent_.w, highlightCurrent_.h, + *theme.inputPanel->itemRadius); + } + candidateListChanged_ = false; + } + + candidateRegions_.clear(); + candidateRegions_.reserve(nCandidates_); + + // Use yoga-based positioning for candidates + for (size_t i = 0; i < nCandidates_; i++) { + float labelLeft = + absolute(candidateNodes_[i].label); + float labelTop = absolute(candidateNodes_[i].label); + + float textLeft = absolute(candidateNodes_[i].text); + float textTop = absolute(candidateNodes_[i].text); + + float commentLeft = + absolute(candidateNodes_[i].comment); + float commentTop = + absolute(candidateNodes_[i].comment); + + float candidateLeft = + absolute(candidateNodes_[i].inner); + float candidateTop = + absolute(candidateNodes_[i].inner); + float candidateWidth = + YGNodeLayoutGetWidth(candidateNodes_[i].inner.get()); + float candidateHeight = + YGNodeLayoutGetHeight(candidateNodes_[i].inner.get()); + + const auto &highlightMargin = *theme.inputPanel->highlight->margin; + const auto &clickMargin = *theme.inputPanel->highlight->clickMargin; + auto highlightWidth = candidateWidth; + bool vertical = parent_->config().verticalCandidateList.value(); + if (layoutHint_ == CandidateLayoutHint::Vertical) { + vertical = true; + } else if (layoutHint_ == CandidateLayoutHint::Horizontal) { + vertical = false; + } + + if (*theme.inputPanel->fullWidthHighlight && vertical) { + // Last candidate, fill. + highlightWidth = width - *margin.marginLeft - *margin.marginRight - + *textMargin.marginRight - *textMargin.marginLeft; + } + + const int highlightIndex = highlight(); + bool highlight = false; + if (highlightIndex >= 0 && i == static_cast(highlightIndex)) { + if (!material) { + cairo_save(cr); + theme.paint(cr, *theme.inputPanel->highlight, + candidateLeft - *highlightMargin.marginLeft, + candidateTop - *highlightMargin.marginTop, + highlightWidth + *highlightMargin.marginLeft + + *highlightMargin.marginRight, + candidateHeight + *highlightMargin.marginTop + + *highlightMargin.marginBottom, + /*alpha=*/1.0); + cairo_restore(cr); + } + highlight = true; + } + + Rect candidateRegion; + candidateRegion + .setPosition(candidateLeft - *highlightMargin.marginLeft + + *clickMargin.marginLeft, + candidateTop - *highlightMargin.marginTop + + *clickMargin.marginTop) + .setSize(highlightWidth + *highlightMargin.marginLeft + + *highlightMargin.marginRight - + *clickMargin.marginLeft - *clickMargin.marginRight, + candidateHeight + *highlightMargin.marginTop + + *highlightMargin.marginBottom - + *clickMargin.marginTop - *clickMargin.marginBottom); + candidateRegions_.push_back(candidateRegion); + + if (candidateLayouts_[i].label.characterCount()) { + candidateLayouts_[i].label.render(cr, labelLeft, labelTop, + highlight); + } + if (candidateLayouts_[i].text.characterCount()) { + candidateLayouts_[i].text.render(cr, textLeft, textTop, highlight); + } + if (candidateLayouts_[i].comment.characterCount()) { + candidateLayouts_[i].comment.render(cr, commentLeft, commentTop, + highlight); + } + } + cairo_restore(cr); + + prevRegion_ = Rect(); + nextRegion_ = Rect(); + if (nCandidates_ && (hasPrev_ || hasNext_)) { + const auto &prev = theme.loadAction(*theme.inputPanel->prev); + const auto &next = theme.loadAction(*theme.inputPanel->next); + if (prev.valid() && next.valid()) { + cairo_save(cr); + int prevY = 0; + int nextY = 0; + switch (*theme.inputPanel->buttonAlignment) { + case PageButtonAlignment::Top: + prevY = nextY = absolute(buttonNode_); + break; + case PageButtonAlignment::FirstCandidate: + prevY = candidateRegions_.front().top() + + ((candidateRegions_.front().height() - prev.height()) / + 2.0); + nextY = candidateRegions_.front().top() + + ((candidateRegions_.front().height() - next.height()) / + 2.0); + break; + case PageButtonAlignment::Center: + prevY = absolute(buttonNode_) + + ((YGNodeLayoutGetHeight(buttonNode_.get()) - + prev.height()) / + 2.0); + nextY = absolute(buttonNode_) + + ((YGNodeLayoutGetHeight(buttonNode_.get()) - + next.height()) / + 2.0); + break; + case PageButtonAlignment::LastCandidate: + prevY = + candidateRegions_.back().top() + + ((candidateRegions_.back().height() - prev.height()) / 2.0); + nextY = + candidateRegions_.back().top() + + ((candidateRegions_.back().height() - next.height()) / 2.0); + break; + case PageButtonAlignment::Bottom: + default: + prevY = absolute(buttonNode_) + + YGNodeLayoutGetHeight(buttonNode_.get()) - + prev.height(); + nextY = absolute(buttonNode_) + + YGNodeLayoutGetHeight(buttonNode_.get()) - + next.height(); + break; + } + nextRegion_.setPosition(absolute(buttonNode_) + + prev.width(), + nextY); + nextRegion_.setSize(next.width(), next.height()); + cairo_translate(cr, nextRegion_.left(), nextRegion_.top()); + + shrink(nextRegion_, *theme.inputPanel->next->clickMargin); + double alpha = 1.0; + if (!hasNext_) { + alpha = 0.3; + } else if (nextHovered_) { + alpha = 0.7; + } + theme.paint(cr, *theme.inputPanel->next, alpha); + cairo_restore(cr); + cairo_save(cr); + prevRegion_.setPosition(absolute(buttonNode_), + prevY); + prevRegion_.setSize(prev.width(), prev.height()); + cairo_translate(cr, prevRegion_.left(), prevRegion_.top()); + shrink(prevRegion_, *theme.inputPanel->prev->clickMargin); + alpha = 1.0; + if (!hasPrev_) { + alpha = 0.3; + } else if (prevHovered_) { + alpha = 0.7; + } + theme.paint(cr, *theme.inputPanel->prev, alpha); + cairo_restore(cr); + } + } + + if (menuOpen_) { + paintMenu(cr, width, menuTop_, fontHeight); + } + + if (m3ui_logcategory().checkLogLevel(Debug)) { + renderYogaNode(cr, rootNode_.get()); + } + + if (material) { + cairo_restore(cr); + cairo_pop_group_to_source(cr); + cairo_paint_with_alpha(cr, showAlpha); + if (needFrame) { + scheduleFrame(); + } + } +} + +void InputWindow::click(int x, int y) { + auto *inputContext = inputContext_.get(); + if (!inputContext) { + return; + } + const auto candidateList = inputContext->inputPanel().candidateList(); + if (!candidateList) { + return; + } + if (menuOpen_) { + // Copy what is needed first, triggering an action rebuilds the list. + int actionId = -1; + const auto candidateIndex = menuCandidate_; + for (size_t i = 0; i < menuRegions_.size(); i++) { + if (menuRegions_[i].contains(x, y)) { + actionId = menuActions_[i].id(); + break; + } + } + closeMenu(); + if (actionId >= 0) { + auto *actionable = candidateList->toActionable(); + const auto *candidate = + nthCandidateIgnorePlaceholder(*candidateList, candidateIndex); + if (actionable && candidate) { + actionable->triggerAction(*candidate, actionId); + } + } + inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); + return; + } + if (auto *pageable = candidateList->toPageable()) { + if (pageable->hasPrev() && prevRegion_.contains(x, y)) { + pageable->prev(); + inputContext->updateUserInterface( + UserInterfaceComponent::InputPanel); + return; + } + if (pageable->hasNext() && nextRegion_.contains(x, y)) { + pageable->next(); + inputContext->updateUserInterface( + UserInterfaceComponent::InputPanel); + return; + } + } + for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) { + if (candidateRegions_[idx].contains(x, y)) { + const auto *candidate = + nthCandidateIgnorePlaceholder(*candidateList, idx); + if (candidate) { + candidate->select(inputContext); + } + break; + } + } +} + +void InputWindow::rightClick(int x, int y) { + auto *inputContext = inputContext_.get(); + if (!inputContext) { + return; + } + const auto candidateList = inputContext->inputPanel().candidateList(); + if (!candidateList) { + return; + } + if (menuOpen_) { + closeMenu(); + inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); + return; + } + auto *actionable = candidateList->toActionable(); + if (!actionable) { + return; + } + for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) { + if (!candidateRegions_[idx].contains(x, y)) { + continue; + } + const auto *candidate = + nthCandidateIgnorePlaceholder(*candidateList, idx); + if (!candidate || !actionable->hasAction(*candidate)) { + return; + } + std::vector actions; + for (auto &action : actionable->candidateActions(*candidate)) { + if (!action.isSeparator()) { + actions.push_back(std::move(action)); + } + } + if (actions.empty()) { + return; + } + menuActions_ = std::move(actions); + menuCandidate_ = idx; + menuHover_ = -1; + menuOpen_ = true; + inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); + return; + } +} + +void InputWindow::closeMenu() { + menuOpen_ = false; + menuCandidate_ = -1; + menuHover_ = -1; + menuActions_.clear(); + menuRegions_.clear(); +} + +std::pair InputWindow::menuSize() { + auto *metrics = pango_context_get_metrics( + context_.get(), pango_context_get_font_description(context_.get()), + pango_context_get_language(context_.get())); + const int fontHeight = PANGO_PIXELS(pango_font_metrics_get_ascent(metrics) + + pango_font_metrics_get_descent(metrics)); + pango_font_metrics_unref(metrics); + menuItemHeight_ = fontHeight + 16; + menuTextWidth_ = 0; + for (const auto &action : menuActions_) { + auto layout = newPangoLayout(context_.get()); + pango_layout_set_text(layout.get(), action.text().c_str(), -1); + int w = 0; + int h = 0; + pango_layout_get_pixel_size(layout.get(), &w, &h); + menuTextWidth_ = std::max(menuTextWidth_, w); + } + // 1px divider + padding, item padding of 16 and a margin of 8 each side. + return {menuTextWidth_ + 2 * 16 + 2 * 8, + 1.0 + 6 + menuActions_.size() * menuItemHeight_ + 6}; +} + +void InputWindow::paintMenu(cairo_t *cr, double width, double yTop, + double /*fontHeight*/) { + auto &theme = parent_->theme(); + cairo_save(cr); + auto line = *theme.inputPanel->outlineColor; + if (line.alpha() == 0) { + const auto &text = theme.inputPanelText(); + line = Color(text.red(), text.green(), text.blue(), 40); + } + cairoSetSourceColor(cr, line); + cairo_rectangle(cr, 12, yTop, width - 24, 1); + cairo_fill(cr); + + menuRegions_.clear(); + double y = yTop + 1 + 6; + for (size_t i = 0; i < menuActions_.size(); i++) { + Rect region; + region.setPosition(8, y).setSize(width - 16, menuItemHeight_); + menuRegions_.push_back(region); + const bool hovered = static_cast(i) == menuHover_; + if (hovered) { + fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, 8, y, + width - 16, menuItemHeight_, + *theme.inputPanel->itemRadius); + } + auto layout = newPangoLayout(context_.get()); + pango_layout_set_text(layout.get(), menuActions_[i].text().c_str(), -1); + int w = 0; + int h = 0; + pango_layout_get_pixel_size(layout.get(), &w, &h); + cairoSetSourceColor(cr, hovered + ? theme.inputPanelHighlightCandidateText() + : theme.inputPanelText()); + renderLayout(cr, layout.get(), 8 + 16, y + (menuItemHeight_ - h) / 2.0); + y += menuItemHeight_; + } + cairo_restore(cr); +} + +bool InputWindow::materialStyle() const { + return parent_->theme().inputPanel->surfaceColor->alpha() > 0; +} + +bool InputWindow::animationsEnabled() const { + return materialStyle() && *parent_->config().animations; +} + +void InputWindow::startShowAnimation() { + highlightValid_ = false; + if (!animationsEnabled()) { + showAnimating_ = false; + return; + } + showStart_ = Clock::now(); + showAnimating_ = true; +} + +void InputWindow::scheduleFrame() { + if (frameScheduled_) { + return; + } + frameScheduled_ = true; + const auto nextFrame = + std::chrono::duration_cast( + (Clock::now() + std::chrono::milliseconds(16)).time_since_epoch()) + .count(); + if (!frameTimer_) { + frameTimer_ = parent_->instance()->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, nextFrame, 1000, + [this](EventSourceTime *, uint64_t) { + frameScheduled_ = false; + repaint(); + return true; + }); + frameTimer_->setOneShot(); + } else { + frameTimer_->setTime(nextFrame); + frameTimer_->setOneShot(); + frameTimer_->setEnabled(true); + } +} + +void InputWindow::wheel(bool up) { + if (!*parent_->config().useWheelForPaging) { + return; + } + auto *inputContext = inputContext_.get(); + if (!inputContext) { + return; + } + const auto candidateList = inputContext->inputPanel().candidateList(); + if (!candidateList) { + return; + } + if (auto *pageable = candidateList->toPageable()) { + if (up) { + if (pageable->hasPrev()) { + pageable->prev(); + inputContext->updateUserInterface( + UserInterfaceComponent::InputPanel); + } + } else { + if (pageable->hasNext()) { + pageable->next(); + inputContext->updateUserInterface( + UserInterfaceComponent::InputPanel); + } + } + } +} + +void InputWindow::setFontDPI(int dpi) { + // Unlike pango cairo context, Cairo font map does not accept negative dpi. + // Restore to default value instead. + if (dpi <= 0) { + pango_cairo_font_map_set_resolution( + PANGO_CAIRO_FONT_MAP(fontMap_.get()), fontMapDefaultDPI_); + } else { + pango_cairo_font_map_set_resolution( + PANGO_CAIRO_FONT_MAP(fontMap_.get()), dpi); + } + pango_cairo_context_set_resolution(context_.get(), dpi); +} + +int InputWindow::highlight() const { + if (menuOpen_ && menuCandidate_ >= 0) { + return menuCandidate_; + } + int highlightIndex = (hoverIndex_ >= 0) ? hoverIndex_ : candidateIndex_; + return highlightIndex; +} + +bool InputWindow::hover(int x, int y) { + bool needRepaint = false; + + bool prevHovered = false; + bool nextHovered = false; + auto oldHighlight = highlight(); + hoverIndex_ = -1; + + const auto oldMenuHover = menuHover_; + menuHover_ = -1; + for (size_t i = 0; menuOpen_ && i < menuRegions_.size(); i++) { + if (menuRegions_[i].contains(x, y)) { + menuHover_ = i; + break; + } + } + needRepaint = needRepaint || oldMenuHover != menuHover_; + + prevHovered = prevRegion_.contains(x, y); + if (!prevHovered) { + nextHovered = nextRegion_.contains(x, y); + if (!nextHovered && !menuOpen_) { + for (int idx = 0, e = candidateRegions_.size(); idx < e; idx++) { + if (candidateRegions_[idx].contains(x, y)) { + hoverIndex_ = idx; + break; + } + } + } + } + + needRepaint = needRepaint || prevHovered_ != prevHovered; + prevHovered_ = prevHovered; + + needRepaint = needRepaint || nextHovered_ != nextHovered; + nextHovered_ = nextHovered; + + needRepaint = needRepaint || oldHighlight != highlight(); + return needRepaint; +} + +void InputWindow::updateYogaLayout() { + auto &theme = parent_->theme(); + const auto &margin = *theme.inputPanel->contentMargin; + const auto &textMargin = *theme.inputPanel->textMargin; + + // Get font metrics for calculations + auto *metrics = pango_context_get_metrics( + context_.get(), pango_context_get_font_description(context_.get()), + pango_context_get_language(context_.get())); + auto fontHeight = pango_font_metrics_get_ascent(metrics) + + pango_font_metrics_get_descent(metrics); + pango_font_metrics_unref(metrics); + fontHeight = PANGO_PIXELS(fontHeight); + + // Clean up candidate nodes + YGNodeRemoveAllChildren(candidatesNode_.get()); + for (auto &node : candidateNodes_) { + YGNodeRemoveAllChildren(node.self.get()); + YGNodeRemoveAllChildren(node.inner.get()); + } + // Ensure candidate nodes vector has enough elements + while (candidateNodes_.size() < nCandidates_) { + candidateNodes_.emplace_back(); + } + while (candidateNodes_.size() > nCandidates_) { + candidateNodes_.pop_back(); + } + + // Configure root node + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeLeft, *margin.marginLeft); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeRight, *margin.marginRight); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeTop, *margin.marginTop); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeBottom, *margin.marginBottom); + + // Configure and add upper node if it has content + bool hasUpperContent = + pango_layout_get_character_count(upperLayout_.get()) > 0; + YGNodeStyleSetDisplay(upperNode_.get(), + hasUpperContent ? YGDisplayFlex : YGDisplayNone); + if (hasUpperContent) { + YGNodeStyleSetFlexDirection(upperNode_.get(), YGFlexDirectionColumn); + YGNodeStyleSetPadding(upperNode_.get(), YGEdgeLeft, + *textMargin.marginLeft); + YGNodeStyleSetPadding(upperNode_.get(), YGEdgeRight, + *textMargin.marginRight); + YGNodeStyleSetPadding(upperNode_.get(), YGEdgeTop, + *textMargin.marginTop); + YGNodeStyleSetPadding(upperNode_.get(), YGEdgeBottom, + *textMargin.marginBottom); + + int w; + int h; + pango_layout_get_pixel_size(upperLayout_.get(), &w, &h); + YGNodeStyleSetWidth(upperTextNode_.get(), w); + YGNodeStyleSetHeight(upperTextNode_.get(), fontHeight); + } + + // Configure and add lower node if it has content + bool hasAuxDown = pango_layout_get_character_count(lowerLayout_.get()) > 0; + bool hasLowerContent = hasAuxDown || nCandidates_ > 0; + YGNodeStyleSetDisplay(lowerNode_.get(), + hasLowerContent ? YGDisplayFlex : YGDisplayNone); + YGNodeStyleSetDisplay(auxDownNode_.get(), + hasAuxDown ? YGDisplayFlex : YGDisplayNone); + if (hasAuxDown) { + YGNodeStyleSetPadding(auxDownNode_.get(), YGEdgeLeft, + *textMargin.marginLeft); + YGNodeStyleSetPadding(auxDownNode_.get(), YGEdgeRight, + *textMargin.marginRight); + YGNodeStyleSetPadding(auxDownNode_.get(), YGEdgeTop, + *textMargin.marginTop); + YGNodeStyleSetPadding(auxDownNode_.get(), YGEdgeBottom, + *textMargin.marginBottom); + + int w; + int h; + pango_layout_get_pixel_size(lowerLayout_.get(), &w, &h); + YGNodeStyleSetWidth(auxDownTextNode_.get(), w); + YGNodeStyleSetHeight(auxDownTextNode_.get(), fontHeight); + } + + // Add candidates node if there are candidates + if (nCandidates_ > 0) { + // Configure candidates node based on layout hint + bool vertical = parent_->config().verticalCandidateList.value(); + if (layoutHint_ == CandidateLayoutHint::Vertical) { + vertical = true; + } else if (layoutHint_ == CandidateLayoutHint::Horizontal) { + vertical = false; + } + + YGNodeStyleSetFlexDirection(lowerNode_.get(), + vertical ? YGFlexDirectionColumn + : YGFlexDirectionRow); + YGNodeStyleSetFlexDirection(candidatesNode_.get(), + vertical ? YGFlexDirectionColumn + : YGFlexDirectionRow); + + // Configure individual candidate nodes + for (size_t i = 0; i < nCandidates_; i++) { + int labelW = 0; + int labelH = 0; + int candidateW = 0; + int candidateH = 0; + int commentW = 0; + int commentH = 0; + + int labelFontHeight = fontHeight; + if (auto height = candidateLayouts_[i].label.fontHeight()) { + labelFontHeight = height; + } + + int commentFontHeight = fontHeight; + if (auto height = candidateLayouts_[i].comment.fontHeight()) { + commentFontHeight = height; + } + if (candidateLayouts_[i].label.characterCount()) { + labelW = candidateLayouts_[i].label.width(); + } + labelH = labelFontHeight * + std::max(1, candidateLayouts_[i].label.size()); + if (candidateLayouts_[i].text.characterCount()) { + candidateW = candidateLayouts_[i].text.width(); + } + candidateH = + fontHeight * std::max(1, candidateLayouts_[i].text.size()); + if (candidateLayouts_[i].comment.characterCount()) { + commentW = candidateLayouts_[i].comment.width(); + } + commentH = commentFontHeight * + std::max(1, candidateLayouts_[i].comment.size()); + auto &candidate = candidateNodes_[i]; + + YGNodeStyleSetFlexDirection(candidate.inner.get(), + YGFlexDirectionRow); + YGNodeStyleSetAlignSelf(candidate.text.get(), YGAlignCenter); + YGNodeStyleSetAlignSelf(candidate.label.get(), YGAlignCenter); + YGNodeStyleSetAlignSelf(candidate.comment.get(), YGAlignCenter); + YGNodeStyleSetWidth(candidate.label.get(), labelW); + YGNodeStyleSetHeight(candidate.label.get(), labelH); + YGNodeStyleSetWidth(candidate.text.get(), candidateW); + YGNodeStyleSetHeight(candidate.text.get(), candidateH); + YGNodeStyleSetWidth(candidate.comment.get(), commentW); + YGNodeStyleSetHeight(candidate.comment.get(), commentH); + + YGNodeStyleSetMargin(candidate.inner.get(), YGEdgeLeft, + *textMargin.marginLeft); + YGNodeStyleSetMargin(candidate.inner.get(), YGEdgeRight, + *textMargin.marginRight); + YGNodeStyleSetMargin(candidate.inner.get(), YGEdgeTop, + *textMargin.marginTop); + YGNodeStyleSetMargin(candidate.inner.get(), YGEdgeBottom, + *textMargin.marginBottom); + + YGNodeInsertChild(candidatesNode_.get(), candidate.self.get(), i); + YGNodeInsertChild(candidate.self.get(), candidate.inner.get(), 0); + YGNodeInsertChild(candidate.inner.get(), candidate.label.get(), 0); + YGNodeInsertChild(candidate.inner.get(), candidate.text.get(), 1); + YGNodeInsertChild(candidate.inner.get(), candidate.comment.get(), + 2); + } + } + YGNodeStyleSetDisplay(buttonNode_.get(), YGDisplayNone); + // Add prev/next button widths if needed + if (nCandidates_ && (hasPrev_ || hasNext_)) { + const auto &prev = theme.loadAction(*theme.inputPanel->prev); + const auto &next = theme.loadAction(*theme.inputPanel->next); + if (prev.valid() && next.valid()) { + + YGNodeStyleSetDisplay(buttonNode_.get(), YGDisplayFlex); + YGNodeStyleSetWidth(buttonNode_.get(), prev.width() + next.width()); + } + } + + // Calculate layout + YGNodeCalculateLayout(rootNode_.get(), YGUndefined, YGUndefined, + YGDirectionLTR); +} + +void InputWindow::renderYogaNode(cairo_t *cr, YGNodeRef node) { + if (!node) { + return; + } + + // Save current cairo state + cairo_save(cr); + + // Get node layout info + float left = YGNodeLayoutGetLeft(node); + float top = YGNodeLayoutGetTop(node); + float width = YGNodeLayoutGetWidth(node); + float height = YGNodeLayoutGetHeight(node); + + // Convert to integer pixels for better rendering + int nodeX = static_cast(left); + int nodeY = static_cast(top); + int nodeWidth = static_cast(width); + int nodeHeight = static_cast(height); + + // Move to node position + cairo_translate(cr, nodeX, nodeY); + + // Draw border for debugging with different colors for different node types + Color color; + if (node == rootNode_.get()) { + color = Color(0, 0, 255, 128); // Blue for root + } else if (node == upperNode_.get() || node == upperTextNode_.get()) { + color = Color(0, 255, 0, 128); // Green for upper + } else if (node == lowerNode_.get() || node == auxDownNode_.get()) { + color = Color(255, 255, 0, 128); // Yellow for lower + } else if (node == candidatesNode_.get()) { + color = Color(255, 0, 255, 128); // Magenta for candidates + } else { + color = Color(255, 0, 0, 76); // Red for individual candidates + } + cairoSetSourceColor(cr, color); + + cairo_rectangle(cr, 0, 0, nodeWidth, nodeHeight); + cairo_stroke(cr); + + // Recursively render children + uint32_t childCount = YGNodeGetChildCount(node); + for (uint32_t i = 0; i < childCount; i++) { + YGNodeRef child = YGNodeGetChild(node, i); + renderYogaNode(cr, child); + } + + // Restore cairo state + cairo_restore(cr); +} + +} // namespace fcitx::m3ui diff --git a/src/inputwindow.h b/src/inputwindow.h new file mode 100644 index 0000000..692000b --- /dev/null +++ b/src/inputwindow.h @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_INPUTWINDOW_H_ +#define _FCITX_UI_CLASSIC_INPUTWINDOW_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/event.h" +#include "fcitx-utils/macros.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/textformatflags.h" +#include "fcitx-utils/trackableobject.h" +#include "fcitx/candidateaction.h" +#include "fcitx/candidatelist.h" +#include "fcitx/inputcontext.h" +#include "fcitx/text.h" +#include "common.h" + +namespace fcitx::m3ui { + +class M3UI; + +using PangoAttrListUniquePtr = UniqueCPtr; + +class MultilineLayout { +public: + MultilineLayout() = default; + FCITX_INLINE_DEFINE_DEFAULT_DTOR_AND_MOVE(MultilineLayout); + + void contextChanged() { + for (const auto &layout : lines_) { + pango_layout_context_changed(layout.get()); + } + } + void setFontDescription(const PangoFontDescription *fontDesc) { + for (const auto &layout : lines_) { + pango_layout_set_font_description(layout.get(), fontDesc); + } + } + int fontHeight() const { + if (lines_.empty()) { + return 0; + } + auto *context = pango_layout_get_context(lines_[0].get()); + if (!context) { + return 0; + } + const auto *fontDescription = + pango_layout_get_font_description(lines_[0].get()); + if (!fontDescription) { + fontDescription = pango_context_get_font_description(context); + } + if (!fontDescription) { + return 0; + } + auto *metrics = pango_context_get_metrics( + context, fontDescription, pango_context_get_language(context)); + int fontHeight = pango_font_metrics_get_ascent(metrics) + + pango_font_metrics_get_descent(metrics); + pango_font_metrics_unref(metrics); + return PANGO_PIXELS(fontHeight); + } + int characterCount() const { + int count = 0; + for (const auto &layout : lines_) { + count += pango_layout_get_character_count(layout.get()); + } + return count; + } + + int width() const; + + int size() const { return lines_.size(); } + void render(cairo_t *cr, int x, int y, bool highlight); + + std::vector> lines_; + std::vector attrLists_; + std::vector highlightAttrLists_; +}; + +class InputWindow { +public: + InputWindow(M3UI *parent); + ~InputWindow() = default; + std::pair update(InputContext *inputContext); + void paint(cairo_t *cr, double width, double height); + void hide(); + bool visible() const { return visible_; } + bool hover(int x, int y); + void click(int x, int y); + // Right click on a candidate opens the action menu (forget, pin, ...). + void rightClick(int x, int y); + void wheel(bool up); + // Repaint the window using the current state, implemented by backends. + virtual void repaint() {} + +protected: + enum class TextType { Label, Regular, Comment }; + + // Override the font DPI in cairo/pango context. If less-equal than zero, it + // will restore to font map default dpi. + void setFontDPI(int dpi); + void resizeCandidates(size_t n); + void appendText(std::string &s, PangoAttrList *attrList, + PangoAttrList *highlightAttrList, const Text &text, + TextType type); + void insertAttr(PangoAttrList *attrList, TextFormatFlags format, int start, + int end, bool highlight, TextType type) const; + void setTextToLayout( + InputContext *inputContext, PangoLayout *layout, + PangoAttrListUniquePtr *attrList, + PangoAttrListUniquePtr *highlightAttrList, + std::initializer_list> texts, + TextType type = TextType::Regular); + void setTextToMultilineLayout(InputContext *inputContext, + MultilineLayout &layout, const Text &text, + TextType type); + int highlight() const; + void updateYogaLayout(); + void renderYogaNode(cairo_t *cr, YGNodeRef node); + + M3UI *parent_; + GObjectUniquePtr fontMap_; + double fontMapDefaultDPI_ = 96.0; + GObjectUniquePtr context_; + GObjectUniquePtr upperLayout_; + GObjectUniquePtr lowerLayout_; + struct CandidateLayout { + MultilineLayout label; + MultilineLayout text; + MultilineLayout comment; + }; + std::vector candidateLayouts_; + std::vector candidateRegions_; + TrackableObjectReference inputContext_; + bool visible_ = false; + int cursor_ = 0; + size_t nCandidates_ = 0; + bool hasPrev_ = false; + bool hasNext_ = false; + Rect prevRegion_; + Rect nextRegion_; + bool prevHovered_ = false; + bool nextHovered_ = false; + int candidateIndex_ = -1; + CandidateLayoutHint layoutHint_ = CandidateLayoutHint::NotSet; + int hoverIndex_ = -1; + + using Clock = std::chrono::steady_clock; + struct RectF { + double x = 0, y = 0, w = 0, h = 0; + bool operator==(const RectF &o) const { + return x == o.x && y == o.y && w == o.w && h == o.h; + } + }; + // Animation state, only used by themes with a Material surface color. + bool materialStyle() const; + bool animationsEnabled() const; + void scheduleFrame(); + void startShowAnimation(); + Clock::time_point showStart_{}; + bool showAnimating_ = false; + RectF highlightCurrent_; + RectF highlightFrom_; + RectF highlightTarget_; + Clock::time_point highlightStart_{}; + bool highlightValid_ = false; + bool highlightAnimating_ = false; + std::string candidateSignature_; + bool candidateListChanged_ = true; + std::unique_ptr frameTimer_; + bool frameScheduled_ = false; + + // Candidate action menu. + void closeMenu(); + void paintMenu(cairo_t *cr, double width, double yTop, double fontHeight); + std::pair menuSize(); + bool menuOpen_ = false; + int menuCandidate_ = -1; + int menuHover_ = -1; + std::vector menuActions_; + std::vector menuRegions_; + double menuTop_ = 0; + int menuItemHeight_ = 0; + std::vector menuLabels_; + double menuTextWidth_ = 0; + +private: + std::pair sizeHint(); + + using YGNodePtr = fcitx::UniqueCPtr; + YGNodePtr rootNode_; + YGNodePtr mainNode_; + YGNodePtr upperNode_; + YGNodePtr upperTextNode_; + YGNodePtr lowerNode_; + YGNodePtr auxDownNode_; + YGNodePtr auxDownTextNode_; + YGNodePtr candidatesNode_; + + struct CandidateNode { + YGNodePtr self{YGNodeNew()}; + YGNodePtr inner{YGNodeNew()}; + YGNodePtr label{YGNodeNew()}; + YGNodePtr text{YGNodeNew()}; + YGNodePtr comment{YGNodeNew()}; + }; + + std::vector candidateNodes_; + YGNodePtr buttonNode_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_INPUTWINDOW_H_ diff --git a/src/plasmathemewatchdog.cpp b/src/plasmathemewatchdog.cpp new file mode 100644 index 0000000..2547330 --- /dev/null +++ b/src/plasmathemewatchdog.cpp @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2022~2022 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "plasmathemewatchdog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/event.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/fs.h" +#include "fcitx-utils/standardpaths.h" +#include "fcitx-utils/unixfd.h" +#include "common.h" + +#if defined(__FreeBSD__) +#include +#elif defined(__linux__) +#include +#endif + +namespace fcitx::m3ui { + +#define PLASMA_THEME_GENERATOR "fcitx5-plasma-theme-generator"; + +bool PlasmaThemeWatchdog::isAvailable() { + static const std::string binaryName = PLASMA_THEME_GENERATOR; + return StandardPaths::hasExecutable(binaryName); +} + +PlasmaThemeWatchdog::PlasmaThemeWatchdog(EventLoop *event, + std::function callback) + : callback_(std::move(callback)) { + int pipefd[2]; + int ret = ::pipe(pipefd); + if (ret == -1) { + throw std::runtime_error("Failed to create pipe"); + } + ::fcntl(pipefd[0], F_SETFD, FD_CLOEXEC); + ::fcntl(pipefd[0], F_SETFL, ::fcntl(pipefd[0], F_GETFL) | O_NONBLOCK); + ::fcntl(pipefd[1], F_SETFL, ::fcntl(pipefd[1], F_GETFL) | O_NONBLOCK); + auto pid = fork(); + if (pid == 0) { + close(pipefd[0]); +#if defined(__FreeBSD__) + int procctl_value = SIGKILL; + procctl(P_PID, 0, PROC_PDEATHSIG_CTL, &procctl_value); +#elif defined(__linux__) + prctl(PR_SET_PDEATHSIG, SIGKILL); +#endif + // Redirect stdin to /dev/null. + UnixFD fd = UnixFD::own(open("/dev/null", O_RDWR | O_CLOEXEC)); + + if (!fd.isValid()) { + _exit(1); + } + + if (fd.fd() < 3) { + UnixFD copyFd(fd.fd(), 3); + if (!copyFd.isValid()) { + _exit(1); + } + fd = std::move(copyFd); + } + assert(fd.fd() >= 3); + if (dup2(fd.fd(), STDIN_FILENO) < 0) { + _exit(1); + } + fd.reset(); + + signal(SIGINT, SIG_IGN); + char arg0[] = PLASMA_THEME_GENERATOR; + char arg1[] = "--fd"; + std::string value = std::to_string(pipefd[1]); + char *args[] = {arg0, arg1, value.data(), nullptr}; + execvp(arg0, args); + _exit(1); + } else { + close(pipefd[1]); + monitorFD_.give(pipefd[0]); + generator_ = pid; + running_ = true; + ioEvent_ = event->addIOEvent( + monitorFD_.fd(), + {IOEventFlag::In, IOEventFlag::Err, IOEventFlag::Hup}, + [this, event](EventSourceIO *, int fd, IOEventFlags flags) { + if (flags.testAny( + IOEventFlags{IOEventFlag::Err, IOEventFlag::Hup})) { + cleanup(); + return true; + } + if (!flags.test(IOEventFlag::In)) { + return true; + } + ssize_t result = 0; + do { + uint8_t dummy; + result = fs::safeRead(fd, &dummy, sizeof(dummy)); + } while (result > 0); + // Check if pipe EOF, or other errors. + if (result == 0 || (result < 0 && errno != EAGAIN)) { + cleanup(); + return true; + } + timerEvent_ = event->addTimeEvent( + CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 1000000, 0, + [this](EventSourceTime *, uint64_t) { + callback_(); + return true; + }); + return true; + }); + } +} +PlasmaThemeWatchdog::~PlasmaThemeWatchdog() { + destruct_ = true; + cleanup(); +} + +void PlasmaThemeWatchdog::cleanup() { + running_ = false; + CLASSICUI_INFO() << "Cleanup Plasma Theme generator."; + if (!destruct_) { + // For certain distribution, it is possible that + // fcitx5-plasma-theme-generator command presents, but not usable since + // plasma is set to be an optional dependency to fcitx5-configtool. If + // that happens, we need to notify to reload the theme. + callback_(); + } + if (generator_ == 0) { + return; + } + int stat = 0; + int ret; + kill(generator_, SIGKILL); + do { + ret = waitpid(generator_, &stat, WNOHANG); + } while (ret == -1 && errno == EINTR); + generator_ = 0; + ioEvent_.reset(); +} + +} // namespace fcitx::m3ui diff --git a/src/plasmathemewatchdog.h b/src/plasmathemewatchdog.h new file mode 100644 index 0000000..65b707e --- /dev/null +++ b/src/plasmathemewatchdog.h @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2022~2022 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX5_UI_CLASSIC_PLASMATHEMEWATCHDOG_H_ +#define _FCITX5_UI_CLASSIC_PLASMATHEMEWATCHDOG_H_ + +#include +#include +#include +#include +#include "fcitx-utils/event.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/unixfd.h" + +namespace fcitx::m3ui { + +class PlasmaThemeWatchdog { +public: + PlasmaThemeWatchdog(EventLoop *event, std::function callback); + + ~PlasmaThemeWatchdog(); + + bool isRunning() const { return running_; } + + static bool isAvailable(); + +private: + void cleanup(); + std::function callback_; + UnixFD monitorFD_; + std::unique_ptr ioEvent_; + std::unique_ptr timerEvent_; + pid_t generator_ = 0; + bool destruct_ = false; + bool running_ = false; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX5_UI_CLASSIC_PLASMATHEMEWATCHDOG_H_ diff --git a/src/portalsettingmonitor.cpp b/src/portalsettingmonitor.cpp new file mode 100644 index 0000000..2184d8f --- /dev/null +++ b/src/portalsettingmonitor.cpp @@ -0,0 +1,151 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "portalsettingmonitor.h" +#include +#include +#include +#include +#include +#include "fcitx-utils/dbus/bus.h" +#include "fcitx-utils/dbus/matchrule.h" +#include "fcitx-utils/dbus/message.h" +#include "fcitx-utils/misc_p.h" +#include "common.h" + +namespace fcitx { +namespace { +constexpr const char XDG_PORTAL_DESKTOP_SERVICE[] = + "org.freedesktop.portal.Desktop"; +constexpr const char XDG_PORTAL_DESKTOP_PATH[] = + "/org/freedesktop/portal/desktop"; +constexpr const char XDG_PORTAL_DESKTOP_SETTINGS_INTERFACE[] = + "org.freedesktop.portal.Settings"; +constexpr size_t PORTAL_RETRY_LIMIT = 3; +} // namespace + +PortalSettingMonitor::PortalSettingMonitor(dbus::Bus &bus) + : bus_(bus), serviceWatcher_(bus), + watcherMap_( + [this](const PortalSettingKey &key) { + PortalSettingData data{}; + data.matchSlot = bus_.addMatch( + dbus::MatchRule(XDG_PORTAL_DESKTOP_SERVICE, + XDG_PORTAL_DESKTOP_PATH, + XDG_PORTAL_DESKTOP_SETTINGS_INTERFACE, + "SettingChanged", {key.interface, key.name}), + [this, key](dbus::Message &msg) { + std::string interface; + std::string name; + msg >> interface >> name; + if (interface != key.interface || name != key.name) { + return true; + } + + dbus::Variant variant; + msg >> variant; + if (variant.signature() == "v") { + variant = variant.dataAs(); + } + watcherData_[key].querySlot.reset(); + + auto view = watcherMap_.view(key); + for (auto &entry : view) { + entry(variant); + } + return false; + }); + if (!data.matchSlot) { + return false; + } + auto [iter, success] = watcherData_.emplace( + std::piecewise_construct, std::forward_as_tuple(key), + std::forward_as_tuple(std::move(data))); + iter->second.querySlot = queryValue(key); + return true; + }, + [this](const PortalSettingKey &key) { watcherData_.erase(key); }) { + + serviceWatcherEntry_ = serviceWatcher_.watchService( + XDG_PORTAL_DESKTOP_SERVICE, + [this](const std::string &, const std::string &, + const std::string &newOwner) { + setPortalServiceOwner(newOwner); + }); +} + +std::unique_ptr +PortalSettingMonitor::watch(const std::string &interface, + const std::string &name, + PortalSettingCallback callback) { + return watcherMap_.add({interface, name}, callback); +} + +void PortalSettingMonitor::setPortalServiceOwner(const std::string &name) { + if (serviceName_ == name) { + return; + } + serviceName_ = name; + if (serviceName_.empty()) { + return; + } + + CLASSICUI_INFO() << "A new portal show up, start a new query."; + for (auto &[key, data] : watcherData_) { + // Reset retry since it's a new service. + data.retry = 0; + data.querySlot = queryValue(key); + } +} + +std::unique_ptr +PortalSettingMonitor::queryValue(const PortalSettingKey &key) { + auto call = bus_.createMethodCall( + XDG_PORTAL_DESKTOP_SERVICE, XDG_PORTAL_DESKTOP_PATH, + XDG_PORTAL_DESKTOP_SETTINGS_INTERFACE, "Read"); + call << key.interface << key.name; + return call.callAsync(5000000, [this, key](dbus::Message &msg) { + // Key does not exist. + auto *data = findValue(watcherData_, key); + if (!data) { + return true; + } + // Key itself may be gone later, put it on the stack. + // XDG portal seems didn't unwrap the variant. + // Check this special case just in case. + if (msg.isError()) { + CLASSICUI_DEBUG() << "DBus call error: " << msg.errorName() << " " + << msg.errorMessage(); + if (msg.errorName() == "org.freedesktop.DBus.Error.NoReply") { + if (data->retry < PORTAL_RETRY_LIMIT) { + data->retry += 1; + data->querySlot = queryValue(key); + } else { + CLASSICUI_DEBUG() + << "Query portal value reaches retry limit."; + } + } + return true; + } + if (msg.signature() != "v") { + return true; + } + dbus::Variant variant; + msg >> variant; + if (variant.signature() == "v") { + variant = variant.dataAs(); + } + auto view = watcherMap_.view(key); + for (auto &entry : view) { + entry(variant); + } + data->querySlot.reset(); + return false; + }); +} + +} // namespace fcitx diff --git a/src/portalsettingmonitor.h b/src/portalsettingmonitor.h new file mode 100644 index 0000000..adf2be3 --- /dev/null +++ b/src/portalsettingmonitor.h @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_PORTALSETTINGMONITOR_H_ +#define _FCITX_UI_CLASSIC_PORTALSETTINGMONITOR_H_ + +#include +#include +#include +#include +#include +#include "fcitx-utils/dbus/bus.h" +#include "fcitx-utils/dbus/servicewatcher.h" +#include "fcitx-utils/dbus/variant.h" +#include "fcitx-utils/handlertable_details.h" +#include "fcitx-utils/macros.h" +#include "fcitx/misc_p.h" + +namespace fcitx { + +struct PortalSettingKey { + std::string interface; + std::string name; + + bool operator==(const PortalSettingKey &other) const { + return interface == other.interface && name == other.name; + } +}; + +} // namespace fcitx + +template <> +struct std::hash { + std::size_t operator()(const fcitx::PortalSettingKey &key) const noexcept { + std::size_t seed = 0; + fcitx::hash_combine(seed, std::hash()(key.interface)); + fcitx::hash_combine(seed, std::hash()(key.name)); + return seed; + } +}; + +namespace fcitx { + +using PortalSettingCallback = std::function; +using PortalSettingEntry = HandlerTableEntry; + +class PortalSettingMonitor { + struct PortalSettingData { + std::unique_ptr matchSlot; + std::unique_ptr querySlot; + size_t retry = 0; + }; + +public: + PortalSettingMonitor(dbus::Bus &bus); + + FCITX_NODISCARD std::unique_ptr + watch(const std::string &interface, const std::string &name, + PortalSettingCallback callback); + +private: + void setPortalServiceOwner(const std::string &name); + std::unique_ptr queryValue(const PortalSettingKey &key); + dbus::Bus &bus_; + std::string serviceName_; + dbus::ServiceWatcher serviceWatcher_; + std::unique_ptr> + serviceWatcherEntry_; + MultiHandlerTable watcherMap_; + std::unordered_map watcherData_; +}; + +} // namespace fcitx + +#endif \ No newline at end of file diff --git a/src/theme.cpp b/src/theme.cpp new file mode 100644 index 0000000..4a3a438 --- /dev/null +++ b/src/theme.cpp @@ -0,0 +1,1292 @@ +/* + * SPDX-FileCopyrightText: 2016-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "theme.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-config/iniparser.h" +#include "fcitx-config/rawconfig.h" +#include "fcitx-utils/color.h" +#include "fcitx-utils/fs.h" +#include "fcitx-utils/log.h" +#include "fcitx-utils/macros.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/misc_p.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/standardpaths.h" +#include "fcitx-utils/stringutils.h" +#include "fcitx-utils/utf8.h" +#include "fcitx/icontheme.h" +#include "fcitx/misc_p.h" +#include "classicui.h" +#include "colorhelper.h" +#include "common.h" + +namespace fcitx::m3ui { + +namespace { + +inline uint32_t charWidth(uint32_t c) { + if (g_unichar_iszerowidth(c)) { + return 0; + } + return g_unichar_iswide(c) ? 2 : 1; +} + +// This is heuristic, but we guarantee that we don't do crazy things with label. +std::pair extractTextForLabel(const std::string &label) { + std::string extracted; + + // We have non white space here because xkb shortDescription have things + // like fr-tg, mon-a1. + auto texts = stringutils::split(label, FCITX_WHITESPACE "-_/|"); + if (texts.empty()) { + return {"", 0}; + } + + size_t currentWidth = 0; + for (uint32_t chr : utf8::MakeUTF8CharRange(texts[0])) { + const auto width = charWidth(chr); + if (currentWidth + width <= 3) { + extracted.append(utf8::UCS4ToUTF8(chr)); + currentWidth += width; + } else { + break; + } + } + + return {extracted, currentWidth}; +} + +cairo_status_t readFromFd(void *closure, unsigned char *data, + unsigned int length) { + int fd = *static_cast(closure); + + while (length) { + auto sz = fs::safeRead(fd, data, length); + if (sz <= 0) { + return CAIRO_STATUS_READ_ERROR; + } + length -= sz; + data += sz; + } + return CAIRO_STATUS_SUCCESS; +} + +ThemeImage::Pixmap pixBufToPixmap(GdkPixbuf *image) { + cairo_format_t format; + cairo_surface_t *surface; + + if (gdk_pixbuf_get_n_channels(image) == 3) { + format = CAIRO_FORMAT_RGB24; + } else { + format = CAIRO_FORMAT_ARGB32; + } + + surface = cairo_image_surface_create(format, gdk_pixbuf_get_width(image), + gdk_pixbuf_get_height(image)); + + gint width; + gint height; + guchar *gdk_pixels; + guchar *cairo_pixels; + int gdk_rowstride; + int cairo_stride; + int n_channels; + int j; + + if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(surface); + return nullptr; + } + + cairo_surface_flush(surface); + + width = gdk_pixbuf_get_width(image); + height = gdk_pixbuf_get_height(image); + gdk_pixels = gdk_pixbuf_get_pixels(image); + gdk_rowstride = gdk_pixbuf_get_rowstride(image); + n_channels = gdk_pixbuf_get_n_channels(image); + cairo_stride = cairo_image_surface_get_stride(surface); + cairo_pixels = cairo_image_surface_get_data(surface); + + for (j = height; j; j--) { + guchar *p = gdk_pixels; + guchar *q = cairo_pixels; + + if (n_channels == 3) { + guchar *end = p + (static_cast(3) * width); + + while (p < end) { +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + q[0] = p[2]; + q[1] = p[1]; + q[2] = p[0]; + q[3] = 0xFF; +#else + q[0] = 0xFF; + q[1] = p[0]; + q[2] = p[1]; + q[3] = p[2]; +#endif + p += 3; + q += 4; + } + } else { + guchar *end = p + (static_cast(4) * width); + guint t1; + guint t2; + guint t3; + +#define MULT(d, c, a, t) \ + G_STMT_START { \ + (t) = ((c) * (a)) + 0x80; \ + (d) = (((t) >> 8) + (t)) >> 8; \ + } \ + G_STMT_END + + while (p < end) { +#if G_BYTE_ORDER == G_LITTLE_ENDIAN + MULT(q[0], p[2], p[3], t1); + MULT(q[1], p[1], p[3], t2); + MULT(q[2], p[0], p[3], t3); + q[3] = p[3]; +#else + q[0] = p[3]; + MULT(q[1], p[0], p[3], t1); + MULT(q[2], p[1], p[3], t2); + MULT(q[3], p[2], p[3], t3); +#endif + + p += 4; + q += 4; + } + +#undef MULT + } + + gdk_pixels += gdk_rowstride; + cairo_pixels += cairo_stride; + } + + cairo_surface_mark_dirty(surface); + return ThemeImage::Pixmap{surface}; +} + +ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) { + if (file.fd() < 0) { + return nullptr; + } + if (path.extension() == ".png") { + int fd = file.fd(); + auto *surface = + cairo_image_surface_create_from_png_stream(readFromFd, &fd); + if (!surface) { + return nullptr; + } + if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) { + g_clear_pointer(&surface, cairo_surface_destroy); + return nullptr; + } + return ThemeImage::Pixmap{surface}; + } + + GObjectUniquePtr stream( + g_unix_input_stream_new(file.fd(), false)); + if (!stream) { + return nullptr; + } + GObjectUniquePtr image( + gdk_pixbuf_new_from_stream(stream.get(), nullptr, nullptr)); + g_input_stream_close(stream.get(), nullptr, nullptr); + if (!image) { + return nullptr; + } + + return pixBufToPixmap(image.get()); +} + +std::optional> svgSize(RsvgHandle *svg) { + double w = 0; + double h = 0; + if (rsvg_handle_get_intrinsic_size_in_pixels(svg, &w, &h)) { + int width = std::max(1, static_cast(std::ceil(w))); + int height = std::max(1, static_cast(std::ceil(h))); + return std::make_pair(width, height); + } + return std::nullopt; +} + +ThemeImage::CairoPattern renderSvg(RsvgHandle *svg, int width, int height) { + const cairo_rectangle_t extents{0, 0, static_cast(width), + static_cast(height)}; + ThemeImage::Pixmap surface( + cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &extents)); + if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + UniqueCPtr context(cairo_create(surface.get())); + if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + const RsvgRectangle viewport{0, 0, static_cast(width), + static_cast(height)}; + if (!rsvg_handle_render_document(svg, context.get(), &viewport, nullptr) || + cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + ThemeImage::CairoPattern pattern( + cairo_pattern_create_for_surface(surface.get())); + if (cairo_pattern_status(pattern.get()) != CAIRO_STATUS_SUCCESS) { + return nullptr; + } + return pattern; +} + +std::optional loadSvg(UnixFD &file) { + if (!file.isValid()) { + return std::nullopt; + } + GObjectUniquePtr stream( + g_unix_input_stream_new(file.fd(), false)); + if (!stream) { + return std::nullopt; + } + GObjectUniquePtr handle(rsvg_handle_new_from_stream_sync( + stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr)); + g_input_stream_close(stream.get(), nullptr, nullptr); + if (!handle) { + return std::nullopt; + } + auto size = svgSize(handle.get()); + if (!size) { + return std::nullopt; + } + ThemeImage::Svg svg; + svg.width = size->first; + svg.height = size->second; + svg.pattern = renderSvg(handle.get(), svg.width, svg.height); + if (!svg.pattern) { + return std::nullopt; + } + return svg; +} + +bool isSvgPath(const std::filesystem::path &path) { + return path.extension() == ".svg" || path.extension() == ".svgz"; +} + +const std::vector &gdkPixbufSupportedFormats() { + const static std::vector formats = []() { + std::unordered_set exts; + std::vector result; + // PNG is supported by cairo. + UniqueCPtr list(gdk_pixbuf_get_formats()); + for (GSList *item = list.get(); item; item = g_slist_next(item)) { + gchar **extension = gdk_pixbuf_format_get_extensions( + static_cast(item->data)); + for (auto *iter = extension; iter && *iter; iter++) { + exts.insert(std::string(*iter)); + } + g_strfreev(extension); + } + + // Only put the common types and we make a preferred order. + for (std::string ext : {"svg", "svgz", "png", "bmp", "xpm"}) { + // png is supported by cairo. + if (ext == "png" || exts.contains(ext)) { + result.push_back("." + ext); + } + } + CLASSICUI_DEBUG() << "Supported image extensions: " << result; + return result; + }(); + + return formats; +} + +constexpr double RoundEpsilon = 1e-3; + +double pixelCeil(double f) { return std::ceil(f - RoundEpsilon); } +double pixelFloor(double f) { return std::floor(f + RoundEpsilon); } + +template +std::optional +createImage(int width, int height, int marginLeft, int marginTop, + int marginRight, int marginBottom, bool retainSourceOffset, + CreatePattern createPattern) { + ThemeImage::NineTiles result; + result.width = width; + result.height = height; + const int sourceX[] = {0, marginLeft, width - marginRight, width}; + const int sourceY[] = {0, marginTop, height - marginBottom, height}; + for (int y = 0; y < 3; y++) { + for (int x = 0; x < 3; x++) { + int tileWidth = sourceX[x + 1] - sourceX[x]; + int tileHeight = sourceY[y + 1] - sourceY[y]; + if (tileWidth <= 0 || tileHeight <= 0) { + continue; + } + auto &resultTile = result.tiles[(y * 3) + x]; + resultTile.width = tileWidth; + resultTile.height = tileHeight; + if (retainSourceOffset) { + resultTile.sourceX = sourceX[x]; + resultTile.sourceY = sourceY[y]; + } + resultTile.pattern = + createPattern(sourceX[x], sourceY[y], tileWidth, tileHeight); + if (cairo_pattern_status(resultTile.pattern.get()) != + CAIRO_STATUS_SUCCESS) { + return std::nullopt; + } + } + } + return result; +} + +std::optional +createImage(cairo_pattern_t *source, int width, int height, int marginLeft = 0, + int marginTop = 0, int marginRight = 0, int marginBottom = 0) { + if (cairo_pattern_status(source) != CAIRO_STATUS_SUCCESS) { + return std::nullopt; + } + return createImage(width, height, marginLeft, marginTop, marginRight, + marginBottom, true, [source](int, int, int, int) { + return ThemeImage::CairoPattern( + cairo_pattern_reference(source)); + }); +} + +std::optional +createImage(ThemeImage::Pixmap pixmap, int marginLeft = 0, int marginTop = 0, + int marginRight = 0, int marginBottom = 0, int scale = 1) { + if (!pixmap) { + return std::nullopt; + } + int sourceWidth = cairo_image_surface_get_width(pixmap.get()); + int sourceHeight = cairo_image_surface_get_height(pixmap.get()); + if (sourceWidth % scale || sourceHeight % scale) { + return std::nullopt; + } + int width = sourceWidth / scale; + int height = sourceHeight / scale; + return createImage( + width, height, marginLeft, marginTop, marginRight, marginBottom, false, + [surface = pixmap.get(), scale](int x, int y, int tileWidth, + int tileHeight) { + ThemeImage::Pixmap tile(cairo_surface_create_for_rectangle( + surface, x * scale, y * scale, tileWidth * scale, + tileHeight * scale)); + ThemeImage::CairoPattern pattern( + cairo_pattern_create_for_surface(tile.get())); + cairo_matrix_t matrix; + cairo_matrix_init_scale(&matrix, scale, scale); + cairo_pattern_set_matrix(pattern.get(), &matrix); + cairo_pattern_set_extend(pattern.get(), CAIRO_EXTEND_PAD); + return pattern; + }); +} + +std::filesystem::path scaledImagePath(const std::filesystem::path &path, + int scale) { + return path.parent_path() / std::format("{}@{}x{}", path.stem().string(), + scale, path.extension().string()); +} + +template +void loadScaledImages(std::map &images, + const std::filesystem::path &path, int supportedScale, + int width, int height, Load load) { + for (int scale = 2; scale <= supportedScale; scale++) { + auto scaledPath = scaledImagePath(path, scale); + auto file = StandardPaths::openPath(scaledPath); + if (auto image = load(scale, file, scaledPath)) { + if (image->width == width && image->height == height) { + images.emplace(scale, std::move(*image)); + } + } + } +} + +template +const Image *findScaledImage(const std::map &images, int scale) { + if (scale == 1) { + return nullptr; + } + auto iter = images.lower_bound(scale); + if (iter != images.end()) { + return &iter->second; + } + return images.empty() ? nullptr : &images.rbegin()->second; +} + +void paintTile(cairo_t *c, double x, double y, double width, double height, + double alpha, const ThemeImage &image, int marginLeft, + int marginTop, int marginRight, int marginBottom) { + + int resizeHeight = image.height() - marginTop - marginBottom; + int resizeWidth = image.width() - marginLeft - marginRight; + + if (resizeHeight <= 0) { + resizeHeight = 1; + } + + if (resizeWidth <= 0) { + resizeWidth = 1; + } + + if (height < 0) { + height = resizeHeight; + } + + if (width < 0) { + width = resizeWidth; + } + const auto targetResizeWidth = width - marginLeft - marginRight; + const auto targetResizeHeight = height - marginTop - marginBottom; + + double gridX[] = {0.0, static_cast(marginLeft), + static_cast(width - marginRight), width}; + double gridY[] = {0.0, static_cast(marginTop), + static_cast(height - marginBottom), height}; + for (double &gx : gridX) { + gx += x; + } + for (double &gy : gridY) { + gy += y; + } + + auto *surface = cairo_get_target(c); + double xScale; + double yScale; + cairo_surface_get_device_scale(surface, &xScale, &yScale); + for (int i = 0; i < 4; i++) { + if (i % 2 == 0) { + gridX[i] = pixelFloor(gridX[i] * xScale) / xScale; + gridY[i] = pixelFloor(gridY[i] * yScale) / yScale; + } else { + gridX[i] = pixelCeil(gridX[i] * xScale) / xScale; + gridY[i] = pixelCeil(gridY[i] * yScale) / yScale; + } + } + + auto part = [&](int ix, int iy) { + double dx = gridX[ix]; + double dy = gridY[iy]; + double dw = gridX[ix + 1] - gridX[ix]; + double dh = gridY[iy + 1] - gridY[iy]; + if (dw > 0 && dh > 0) { + image.paintRegion( + c, static_cast((iy * 3) + ix), dx, dy, + dw, dh, alpha); + } + }; + /* + * 7 8 9 + * 4 5 6 + * 1 2 3 + */ + + if (marginLeft && marginBottom) { + /* part 1 */ + part(0, 2); + } + + if (marginRight && marginBottom) { + /* part 3 */ + part(2, 2); + } + + if (marginLeft && marginTop) { + /* part 7 */ + part(0, 0); + } + + if (marginRight && marginTop) { + /* part 9 */ + part(2, 0); + } + + /* part 2 & 8 */ + if (marginTop && targetResizeWidth > 0) { + part(1, 0); + } + + if (marginBottom && targetResizeWidth > 0) { + part(1, 2); + } + + /* part 4 & 6 */ + if (marginLeft && targetResizeHeight > 0) { + part(0, 1); + } + + if (marginRight && targetResizeHeight > 0) { + part(2, 1); + } + + /* part 5 */ + if (targetResizeHeight > 0 && targetResizeWidth > 0) { + part(1, 1); + } +} + +} // namespace + +ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon, + const std::string &label, uint32_t size, + const M3UI *classicui) + : size_(size) { + bool preferTextIcon = + !label.empty() && + ((icon == "input-keyboard" && + hasTwoKeyboardInCurrentGroup(classicui->instance())) || + *classicui->config().preferTextIcon); + if (!preferTextIcon && !icon.empty()) { + std::filesystem::path iconPath = + iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats()); + auto fd = StandardPaths::openPath(iconPath); + if (isSvgPath(iconPath)) { + if (auto svg = loadSvg(fd)) { + if (auto image = createImage(svg->pattern.get(), svg->width, + svg->height)) { + image_ = std::move(*image); + } + } + } else { + Pixmap pixmap = loadPixmap(fd, iconPath); + if (auto image = createImage(std::move(pixmap))) { + image_ = std::move(*image); + } + } + } + if (!valid()) { + Pixmap textImage( + cairo_image_surface_create(CAIRO_FORMAT_ARGB32, size, size)); + drawTextIcon(textImage.get(), label, size, classicui->config()); + if (auto image = createImage(std::move(textImage))) { + image_ = std::move(*image); + } + } +} + +ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg, + const Color &color, const Color &borderColor) { + if (!cfg.image->empty()) { + std::filesystem::path imagePath; + auto imageFile = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / theme.name() / *cfg.image, + theme.isSystemTheme() ? StandardPathsMode::System + : StandardPathsMode::Default, + &imagePath); + if (isSvgPath(imagePath)) { + if (auto svg = loadSvg(imageFile)) { + if (auto image = createImage( + svg->pattern.get(), svg->width, svg->height, + *cfg.margin->marginLeft, *cfg.margin->marginTop, + *cfg.margin->marginRight, *cfg.margin->marginBottom)) { + image_ = std::move(*image); + } + } + } else { + Pixmap pixmap = loadPixmap(imageFile, imagePath); + if (auto image = createImage( + std::move(pixmap), *cfg.margin->marginLeft, + *cfg.margin->marginTop, *cfg.margin->marginRight, + *cfg.margin->marginBottom)) { + image_ = std::move(*image); + loadScaledImages( + scaledImages_, imagePath, *theme.supportedScale, + image_->width, image_->height, + [&cfg](int scale, UnixFD &file, + const std::filesystem::path &path) { + return createImage( + loadPixmap(file, path), *cfg.margin->marginLeft, + *cfg.margin->marginTop, *cfg.margin->marginRight, + *cfg.margin->marginBottom, scale); + }); + } + } + } + + if (!cfg.overlay->empty()) { + std::filesystem::path imagePath; + auto imageFile = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / theme.name() / *cfg.overlay, + theme.isSystemTheme() ? StandardPathsMode::System + : StandardPathsMode::Default, + &imagePath); + if (isSvgPath(imagePath)) { + if (auto svg = loadSvg(imageFile)) { + if (auto overlay = createImage(svg->pattern.get(), svg->width, + svg->height)) { + overlay_ = std::move(overlay->tiles[4]); + } + } + } else if (auto overlay = + createImage(loadPixmap(imageFile, imagePath))) { + overlay_ = std::move(overlay->tiles[4]); + loadScaledImages( + scaledOverlays_, imagePath, *theme.supportedScale, + overlay_->width, overlay_->height, + [](int scale, UnixFD &file, const std::filesystem::path &path) { + if (auto image = createImage(loadPixmap(file, path), 0, 0, + 0, 0, scale)) { + return std::optional( + std::move(image->tiles[4])); + } + return std::optional(); + }); + } + } + + if (!valid()) { + constexpr auto minimumSize = 20; + auto width = + *cfg.margin->marginLeft + *cfg.margin->marginRight + + std::max(*cfg.margin->marginLeft + *cfg.margin->marginRight, + minimumSize); + auto height = + *cfg.margin->marginTop + *cfg.margin->marginBottom + + std::max(*cfg.margin->marginTop + *cfg.margin->marginBottom, + minimumSize); + + auto borderWidth = + std::min({*cfg.borderWidth, *cfg.margin->marginLeft, + *cfg.margin->marginRight, *cfg.margin->marginTop, + *cfg.margin->marginBottom}); + + CLASSICUI_DEBUG() << "Paint background: height " << height << " width " + << width << " border=" << borderColor + << " border width=" << *cfg.borderWidth + << " color=" << color; + ThemeImage::Pattern pattern; + pattern.width = width; + pattern.height = height; + pattern.borderWidth = borderWidth; + pattern.pattern.reset(cairo_pattern_create_mesh()); + const auto setPatch = [&](double x0, double y0, double x1, double y1, + const Color &color) { + cairo_mesh_pattern_begin_patch(pattern.pattern.get()); + cairo_mesh_pattern_move_to(pattern.pattern.get(), x0, y0); + cairo_mesh_pattern_line_to(pattern.pattern.get(), x1, y0); + cairo_mesh_pattern_line_to(pattern.pattern.get(), x1, y1); + cairo_mesh_pattern_line_to(pattern.pattern.get(), x0, y1); + for (int i = 0; i < 4; i++) { + cairo_mesh_pattern_set_corner_color_rgba( + pattern.pattern.get(), i, color.redF(), color.greenF(), + color.blueF(), color.alphaF()); + } + cairo_mesh_pattern_end_patch(pattern.pattern.get()); + }; + if (borderWidth > 0) { + const double x[] = {0.0, static_cast(borderWidth), + static_cast(width - borderWidth), + static_cast(width)}; + const double y[] = {0.0, static_cast(borderWidth), + static_cast(height - borderWidth), + static_cast(height)}; + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 3; column++) { + setPatch(x[column], y[row], x[column + 1], y[row + 1], + (row == 1 && column == 1) ? color : borderColor); + } + } + } else { + setPatch(0, 0, width, height, color); + } + if (auto image = createImage( + pattern.pattern.get(), pattern.width, pattern.height, + *cfg.margin->marginLeft, *cfg.margin->marginTop, + *cfg.margin->marginRight, *cfg.margin->marginBottom)) { + image_ = std::move(*image); + isPattern_ = true; + } + } +} + +ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) { + if (!cfg.image->empty()) { + std::filesystem::path imagePath; + auto imageFile = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / theme.name() / *cfg.image, + theme.isSystemTheme() ? StandardPathsMode::System + : StandardPathsMode::Default, + &imagePath); + if (isSvgPath(imagePath)) { + if (auto svg = loadSvg(imageFile)) { + if (auto image = createImage(svg->pattern.get(), svg->width, + svg->height)) { + image_ = std::move(*image); + } + } + } else { + if (auto image = createImage(loadPixmap(imageFile, imagePath))) { + image_ = std::move(*image); + loadScaledImages(scaledImages_, imagePath, + *theme.supportedScale, image_->width, + image_->height, + [](int scale, UnixFD &file, + const std::filesystem::path &path) { + return createImage(loadPixmap(file, path), + 0, 0, 0, 0, scale); + }); + } + } + } +} + +void ThemeImage::drawTextIcon(cairo_surface_t *surface, + const std::string &rawLabel, uint32_t size, + const ClassicUIConfig &config) { + auto [label, textWidth] = extractTextForLabel(rawLabel); + auto *cr = cairo_create(surface); + cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); + cairoSetSourceColor(cr, Color("#00000000")); + cairo_paint(cr); + + int pixelSize = size * 0.7 * (textWidth >= 3 ? (2.0 / textWidth) : 1.0); + if (pixelSize < 0) { + pixelSize = 1; + } + auto *fontMap = pango_cairo_font_map_get_default(); + GObjectUniquePtr context( + pango_font_map_create_context(fontMap)); + GObjectUniquePtr layout(pango_layout_new(context.get())); + pango_layout_set_single_paragraph_mode(layout.get(), true); + pango_layout_set_text(layout.get(), label.c_str(), label.size()); + PangoRectangle rect; + PangoFontDescription *desc = + pango_font_description_from_string(config.trayFont->c_str()); + pango_font_description_set_absolute_size(desc, pixelSize * PANGO_SCALE); + pango_layout_set_font_description(layout.get(), desc); + pango_font_description_free(desc); + pango_layout_get_pixel_extents(layout.get(), &rect, nullptr); + cairo_translate(cr, ((size - rect.width) * 0.5) - rect.x, + ((size - rect.height) * 0.5) - rect.y); + if (config.trayBorderColor->alpha()) { + cairo_save(cr); + cairoSetSourceColor(cr, *config.trayBorderColor); + pango_cairo_layout_path(cr, layout.get()); + cairo_set_line_width(cr, std::min(4, (pixelSize + 4) / 8)); + cairo_stroke(cr); + cairo_restore(cr); + } + + cairo_save(cr); + cairoSetSourceColor(cr, *config.trayTextColor); + pango_cairo_show_layout(cr, layout.get()); + cairo_restore(cr); + + cairo_destroy(cr); +} + +void ThemeImage::paintRegion(cairo_t *c, TilePosition tile, double destX, + double destY, double destWidth, double destHeight, + double alpha, bool overlay) const { + if (destWidth <= 0 || destHeight <= 0) { + return; + } + if ((overlay && !overlay_) || (!overlay && !image_)) { + return; + } + const auto scale = [&]() { + double xScale; + double yScale; + cairo_surface_get_device_scale(cairo_get_target(c), &xScale, &yScale); + return static_cast(std::ceil(std::max(xScale, yScale))); + }(); + const NineTiles::Tile *source = nullptr; + if (overlay) { + if (auto scaledOverlay = findScaledImage(scaledOverlays_, scale)) { + source = scaledOverlay; + } else { + source = &*overlay_; + } + } else { + if (auto scaledImage = findScaledImage(scaledImages_, scale)) { + source = &scaledImage->tiles[static_cast(tile)]; + } else { + source = &image_->tiles[static_cast(tile)]; + } + } + if (!source->pattern) { + return; + } + cairo_save(c); + cairo_rectangle(c, destX, destY, destWidth, destHeight); + cairo_clip(c); + cairo_translate(c, destX, destY); + cairo_scale(c, destWidth / source->width, destHeight / source->height); + cairo_translate(c, -source->sourceX, -source->sourceY); + cairo_set_source(c, source->pattern.get()); + cairo_paint_with_alpha(c, alpha); + cairo_restore(c); +} + +Theme::Theme() : iconTheme_(IconTheme::defaultIconThemeName()) {} + +Theme::~Theme() {} + +bool Theme::isSystemThemeName(std::string_view themeName) { + return themeName == "default" || themeName == "default-dark"; +} + +const ThemeImage &Theme::loadBackground(const BackgroundImageConfig &cfg) { + if (auto *image = findValue(backgroundImageTable_, &cfg)) { + return *image; + } + + Color color; + Color borderColor; + if (&cfg == &*inputPanel->background) { + color = inputPanelBackground_; + borderColor = inputPanelBorder_; + } else if (&cfg == &*inputPanel->highlight) { + color = inputPanelHighlightCandidateBackground_; + borderColor = inputPanelHighlightCandidateBorder_; + } else if (&cfg == &*menu->background) { + color = menuBackground_; + borderColor = menuBorder_; + } else if (&cfg == &*menu->highlight) { + color = menuSelectedItemBackground_; + borderColor = menuSelectedItemBorder_; + } else if (&cfg == &*menu->separator) { + color = menuSeparator_; + borderColor = *cfg.borderColor; + } else { + color = *cfg.color; + borderColor = *cfg.borderColor; + } + + auto result = backgroundImageTable_.emplace( + std::piecewise_construct, std::forward_as_tuple(&cfg), + std::forward_as_tuple(*this, cfg, color, borderColor)); + assert(result.second); + return result.first->second; +} + +const ThemeImage &Theme::loadAction(const ActionImageConfig &cfg) { + if (auto *image = findValue(actionImageTable_, &cfg)) { + return *image; + } + + auto result = actionImageTable_.emplace(std::piecewise_construct, + std::forward_as_tuple(&cfg), + std::forward_as_tuple(*this, cfg)); + assert(result.second); + return result.first->second; +} + +const ThemeImage &Theme::loadImage(const std::string &icon, + const std::string &label, uint32_t size, + const M3UI *classicui) { + auto &map = trayImageTable_; + auto name = stringutils::concat("icon:", icon, "label:", label); + if (auto *image = findValue(map, name)) { + if (image->size() == size) { + return *image; + } + map.erase(name); + } + + auto result = map.emplace( + std::piecewise_construct, std::forward_as_tuple(name), + std::forward_as_tuple(iconTheme_, icon, label, size, classicui)); + assert(result.second); + return result.first->second; +} + +void Theme::paint(cairo_t *c, const BackgroundImageConfig &cfg, double dx, + double dy, double width, double height, double alpha) { + const ThemeImage &image = loadBackground(cfg); + auto marginTop = *cfg.margin->marginTop; + auto marginBottom = *cfg.margin->marginBottom; + auto marginLeft = *cfg.margin->marginLeft; + auto marginRight = *cfg.margin->marginRight; + + paintTile(c, dx, dy, width, height, alpha, image, marginLeft, marginTop, + marginRight, marginBottom); + + if (!image.hasOverlay()) { + return; + } + + Rect clipRect; + auto clipWidth = width - *cfg.overlayClipMargin->marginLeft - + *cfg.overlayClipMargin->marginRight; + auto clipHeight = height - *cfg.overlayClipMargin->marginTop - + *cfg.overlayClipMargin->marginBottom; + if (clipWidth <= 0 || clipHeight <= 0) { + return; + } + clipRect + .setPosition(*cfg.overlayClipMargin->marginLeft, + *cfg.overlayClipMargin->marginTop) + .setSize(clipWidth, clipHeight); + + int x = 0; + int y = 0; + switch (*cfg.gravity) { + case Gravity::TopLeft: + case Gravity::CenterLeft: + case Gravity::BottomLeft: + x = *cfg.overlayOffsetX; + break; + case Gravity::TopCenter: + case Gravity::Center: + case Gravity::BottomCenter: + x = ((width - image.overlayWidth()) / 2) + *cfg.overlayOffsetX; + break; + case Gravity::TopRight: + case Gravity::CenterRight: + case Gravity::BottomRight: + x = width - image.overlayWidth() - *cfg.overlayOffsetX; + break; + } + switch (*cfg.gravity) { + case Gravity::TopLeft: + case Gravity::TopCenter: + case Gravity::TopRight: + y = *cfg.overlayOffsetY; + break; + case Gravity::CenterLeft: + case Gravity::Center: + case Gravity::CenterRight: + y = ((height - image.overlayHeight()) / 2) + *cfg.overlayOffsetY; + break; + case Gravity::BottomLeft: + case Gravity::BottomCenter: + case Gravity::BottomRight: + y = height - image.overlayHeight() - *cfg.overlayOffsetY; + break; + } + Rect rect; + rect.setPosition(x, y).setSize(image.overlayWidth(), image.overlayHeight()); + Rect finalRect = rect.intersected(clipRect); + if (finalRect.isEmpty()) { + return; + } + + if (*cfg.hideOverlayIfOversize && !clipRect.contains(rect)) { + return; + } + + cairo_save(c); + cairo_set_operator(c, CAIRO_OPERATOR_OVER); + cairo_rectangle(c, finalRect.left(), finalRect.top(), finalRect.width(), + finalRect.height()); + cairo_clip(c); + image.paintRegion(c, ThemeImage::TilePosition::Center, x, y, + image.overlayWidth(), image.overlayHeight(), alpha, true); + cairo_restore(c); +} + +void Theme::paint(cairo_t *c, const ActionImageConfig &cfg, double alpha) { + const ThemeImage &image = loadAction(cfg); + int height = image.height(); + int width = image.width(); + + image.paintRegion(c, ThemeImage::TilePosition::Center, 0, 0, width, height, + alpha); +} + +void Theme::reset() { + trayImageTable_.clear(); + backgroundImageTable_.clear(); + actionImageTable_.clear(); +} + +void Theme::load(std::string_view name) { + reset(); + isSystemTheme_ = isSystemThemeName(name); + ThemeConfig config; + copyHelper(config); + // Reset the default value to state. + syncDefaultValueToCurrent(); + if (auto themeConfigFile = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / name / "theme.conf", + StandardPathsMode::System); + themeConfigFile.isValid()) { + RawConfig themeConfig; + readFromIni(themeConfig, themeConfigFile.fd()); + Configuration::load(themeConfig, true); + } else { + // No sys file, reset default value. + ThemeConfig config; + copyHelper(config); + } + syncDefaultValueToCurrent(); + if (!isSystemTheme_) { + // For system theme, we don't load user file, as user file is only for + // custom theme. + if (auto themeConfigFile = StandardPaths::global().open( + StandardPathsType::PkgData, + std::filesystem::path("themes") / name / "theme.conf", + StandardPathsMode::User); + themeConfigFile.isValid()) { + // Has user file, load user file data. + RawConfig themeConfig; + readFromIni(themeConfig, themeConfigFile.fd()); + Configuration::load(themeConfig, true); + } + } + name_ = name; + maskConfig_ = *inputPanel->background; + maskConfig_.overlay.setValue(""); + maskConfig_.image.setValue(*inputPanel->blurMask); + menuBlurMaskConfig_ = *menu->background; + menuBlurMaskConfig_.overlay.setValue(""); + menuBlurMaskConfig_.image.setValue(*menu->blurMask); + accentColorFields_ = std::unordered_set( + accentColor.value().begin(), accentColor.value().end()); +} + +void Theme::load(std::string_view name, const RawConfig &rawConfig) { + reset(); + isSystemTheme_ = isSystemThemeName(name); + Configuration::load(rawConfig, true); + name_ = name; +} + +bool Theme::setIconTheme(const std::string &name) { + if (iconTheme_.internalName() != name) { + CLASSICUI_DEBUG() << "New Icon theme: " << name; + iconTheme_ = IconTheme(name); + trayImageTable_.clear(); + return true; + } + return false; +} +std::vector Theme::mask(const BackgroundImageConfig &cfg, int width, + int height) { + UniqueCPtr mask( + cairo_image_surface_create(CAIRO_FORMAT_A1, width, height)); + auto *c = cairo_create(mask.get()); + cairo_set_operator(c, CAIRO_OPERATOR_SOURCE); + paint(c, cfg, 0, 0, width, height, 1); + cairo_destroy(c); + + UniqueCPtr region( + cairo_region_create()); + cairo_rectangle_int_t rect; +#define AddSpan \ + do { \ + rect.x = prev1; \ + rect.y = y; \ + rect.width = x - prev1; \ + rect.height = 1; \ + cairo_region_union_rectangle(region.get(), &rect); \ + } while (0) + const uint8_t zero = 0; + const auto *data = cairo_image_surface_get_data(mask.get()); + auto cairo_stride = cairo_image_surface_get_stride(mask.get()); + constexpr bool little = G_BYTE_ORDER == G_LITTLE_ENDIAN; + int x; + int y; + for (y = 0; y < height; ++y) { + uint8_t all = zero; + int prev1 = -1; + for (x = 0; x < width;) { + uint8_t byte = data[x / 8]; + if (x > width - 8 || byte != all) { + if constexpr (little) { + for (int b = 8; b > 0 && x < width; --b) { + if (!(byte & 0x01) == !all) { + // More of the same + } else { + // A change. + if (all != zero) { + AddSpan; + all = zero; + } else { + prev1 = x; + all = ~zero; + } + } + byte >>= 1; + ++x; + } + } else { + for (int b = 8; b > 0 && x < width; --b) { + if (!(byte & 0x80) == !all) { + // More of the same + } else { + // A change. + if (all != zero) { + AddSpan; + all = zero; + } else { + prev1 = x; + all = ~zero; + } + } + byte <<= 1; + ++x; + } + } + } else { + x += 8; + } + } + if (all != zero) { + AddSpan; + } + data += cairo_stride; + } +#undef AddSpan + std::vector result; + for (int i = 0, e = cairo_region_num_rectangles(region.get()); i < e; i++) { + cairo_region_get_rectangle(region.get(), i, &rect); + result.push_back(Rect() + .setPosition(rect.x, rect.y) + .setSize(rect.width, rect.height)); + } + return result; +} + +void Theme::populateColor(std::optional accent) { + inputPanelBackground_ = *inputPanel->background->color; + inputPanelBorder_ = *inputPanel->background->borderColor; + inputPanelHighlightCandidateBackground_ = *inputPanel->highlight->color; + inputPanelHighlightCandidateBorder_ = *inputPanel->highlight->borderColor; + inputPanelHighlight_ = *inputPanel->highlightBackgroundColor; + inputPanelText_ = *inputPanel->normalColor; + inputPanelHighlightText_ = *inputPanel->highlightColor; + inputPanelHighlightCandidateText_ = *inputPanel->highlightCandidateColor; + + auto inputPanelCandidateLabelText = *inputPanel->candidateLabelColor; + auto inputPanelHighlightCandidateLabelText = + *inputPanel->highlightCandidateLabelColor; + auto inputPanelCandidateCommentText = *inputPanel->candidateCommentColor; + auto inputPanelHighlightCandidateCommentText = + *inputPanel->highlightCandidateCommentColor; + + menuBackground_ = *menu->background->color; + menuBorder_ = *menu->background->borderColor; + menuSelectedItemBackground_ = *menu->highlight->color; + menuSelectedItemBorder_ = *menu->highlight->borderColor; + menuSeparator_ = *menu->separator->color; + menuText_ = *menu->normalColor; + menuSelectedItemText_ = *menu->highlightTextColor; + + if (accent) { + auto [foreground, foregroundDim] = accentForeground(*accent); + for (auto field : accentColorFields_) { + switch (field) { + case ColorField::InputPanel_Background: + inputPanelBackground_ = *accent; + inputPanelText_ = foreground; + if (inputPanelCandidateLabelText) { + inputPanelCandidateLabelText = foreground; + } + if (inputPanelCandidateCommentText) { + inputPanelCandidateCommentText = foregroundDim; + } + break; + case ColorField::InputPanel_Border: + inputPanelBorder_ = *accent; + break; + case ColorField::InputPanel_HighlightCandidateBackground: + inputPanelHighlightCandidateBackground_ = *accent; + inputPanelHighlightCandidateText_ = foreground; + if (inputPanelHighlightCandidateLabelText) { + inputPanelHighlightCandidateLabelText = foreground; + } + if (inputPanelHighlightCandidateCommentText) { + inputPanelHighlightCandidateCommentText = foregroundDim; + } + break; + case ColorField::InputPanel_HighlightCandidateBorder: + inputPanelHighlightCandidateBorder_ = *accent; + break; + case ColorField::InputPanel_Highlight: + inputPanelHighlight_ = *accent; + inputPanelHighlightText_ = foreground; + break; + case ColorField::Menu_Background: + menuBackground_ = *accent; + menuText_ = foreground; + break; + case ColorField::Menu_Border: + menuBorder_ = *accent; + break; + case ColorField::Menu_SelectedItemBackground: + menuSelectedItemBackground_ = *accent; + menuSelectedItemText_ = foreground; + break; + case ColorField::Menu_SelectedItemBorder: + menuSelectedItemBorder_ = *accent; + break; + case ColorField::Menu_Separator: + menuSeparator_ = *accent; + break; + } + } + } + if (inputPanelCandidateLabelText) { + inputPanelCandidateLabelText_ = *inputPanelCandidateLabelText; + } else { + inputPanelCandidateLabelText_ = inputPanelText_; + } + if (inputPanelHighlightCandidateLabelText) { + inputPanelHighlightCandidateLabelText_ = + *inputPanelHighlightCandidateLabelText; + } else { + inputPanelHighlightCandidateLabelText_ = + inputPanelHighlightCandidateText_; + } + if (inputPanelCandidateCommentText) { + inputPanelCandidateCommentText_ = *inputPanelCandidateCommentText; + } else { + inputPanelCandidateCommentText_ = inputPanelText_; + inputPanelCandidateCommentText_.setAlphaF( + inputPanelCandidateCommentText_.alphaF() * 0.6); + } + if (inputPanelHighlightCandidateCommentText) { + inputPanelHighlightCandidateCommentText_ = + *inputPanelHighlightCandidateCommentText; + } else { + inputPanelHighlightCandidateCommentText_ = + inputPanelHighlightCandidateText_; + inputPanelHighlightCandidateCommentText_.setAlphaF( + inputPanelHighlightCandidateCommentText_.alphaF() * 0.6); + } +} + +} // namespace fcitx::m3ui diff --git a/src/theme.h b/src/theme.h new file mode 100644 index 0000000..cda1194 --- /dev/null +++ b/src/theme.h @@ -0,0 +1,469 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_THEME_H_ +#define _FCITX_UI_CLASSIC_THEME_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-config/configuration.h" +#include "fcitx-config/enum.h" +#include "fcitx-config/option.h" +#include "fcitx-config/rawconfig.h" +#include "fcitx-utils/color.h" +#include "fcitx-utils/i18n.h" +#include "fcitx-utils/i18nstring.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx/icontheme.h" +#include "common.h" + +namespace fcitx::m3ui { +enum class Gravity { + TopLeft, + TopCenter, + TopRight, + CenterLeft, + Center, + CenterRight, + BottomLeft, + BottomCenter, + BottomRight +}; +FCITX_CONFIG_ENUM_NAME_WITH_I18N(Gravity, N_("Top Left"), N_("Top Center"), + N_("Top Right"), N_("Center Left"), + N_("Center"), N_("Center Right"), + N_("Bottom Left"), N_("Bottom Center"), + N_("Bottom Right")); + +enum class PageButtonAlignment { + Top, + FirstCandidate, + Center, + LastCandidate, + Bottom +}; +FCITX_CONFIG_ENUM_NAME_WITH_I18N(PageButtonAlignment, N_("Top"), + N_("First Candidate"), N_("Center"), + N_("Last Candidate"), N_("Bottom")); + +enum class ColorField { + InputPanel_Background, + InputPanel_Border, + InputPanel_HighlightCandidateBackground, + InputPanel_HighlightCandidateBorder, + InputPanel_Highlight, + Menu_Background, + Menu_Border, + Menu_SelectedItemBackground, + Menu_SelectedItemBorder, + Menu_Separator, +}; +FCITX_CONFIG_ENUM_NAME_WITH_I18N( + ColorField, N_("Input Panel Background"), N_("Input Panel Border"), + N_("Input Panel Highlight Candidate Background"), + N_("Input Panel Highlight Candidate Border"), N_("Input Panel Highlight"), + N_("Menu Background"), N_("Menu Border"), + N_("Menu Selected Item Background"), N_("Menu Selected Item Border"), + N_("Menu Separator")); + +FCITX_CONFIGURATION( + MarginConfig, + Option marginLeft{this, "Left", _("Margin Left"), 0, + IntConstrain(0)}; + Option marginRight{this, "Right", _("Margin Right"), 0, + IntConstrain(0)}; + Option marginTop{this, "Top", _("Margin Top"), 0, + IntConstrain(0)}; + Option marginBottom{this, "Bottom", _("Margin Bottom"), + 0, IntConstrain(0)};) + +FCITX_CONFIGURATION( + BackgroundImageConfig, + Option image{this, "Image", _("Background Image")}; + OptionWithAnnotation color{ + this, + "Color", + _("Color"), + Color("#ffffff"), + {}, + {}, + {_("This option is only effective if image is not set.")}}; + OptionWithAnnotation borderColor{ + this, + "BorderColor", + _("Border Color"), + Color("#ffffff00"), + {}, + {}, + {_("This option is only effective if image is not set.")}}; + Option, ToolTipAnnotation> + borderWidth{this, + "BorderWidth", + _("Border width"), + 0, + IntConstrain(0), + {}, + {_("This value should be less than any of margin value.")}}; + Option overlay{this, "Overlay", _("Overlay Image")}; + OptionWithAnnotation gravity{ + this, "Gravity", _("Overlay position")}; + Option overlayOffsetX{this, "OverlayOffsetX", _("Overlay X offset")}; + Option overlayOffsetY{this, "OverlayOffsetY", _("Overlay Y offset")}; + Option hideOverlayIfOversize{this, "HideOverlayIfOversize", + _("Hide overlay if size does not fit"), + false}; + Option margin{this, "Margin", _("Margin")}; + Option overlayClipMargin{this, "OverlayClipMargin", + _("Overlay Clip Margin")};) + +FCITX_CONFIGURATION_EXTEND(HighlightBackgroundImageConfig, + BackgroundImageConfig, + Option clickMargin{ + this, "HighlightClickMargin", + _("Highlight Click Margin")};); + +FCITX_CONFIGURATION(ActionImageConfig, + Option image{this, "Image", _("Image")}; + Option clickMargin{this, "ClickMargin", + _("Click Margin")};) + +FCITX_CONFIGURATION( + InputPanelThemeConfig, + Option normalColor{this, "NormalColor", _("Normal text color"), + Color("#000000ff")}; + Option highlightColor{this, "HighlightColor", + _("Highlight text color"), Color("#ffffffff")}; + Option highlightBackgroundColor{this, "HighlightBackgroundColor", + _("Highlight Background color"), + Color("#a5a5a5ff")}; + Option highlightCandidateColor{this, "HighlightCandidateColor", + _("Highlight Candidate Text Color"), + Color("#ffffffff")}; + Option labelTextSizeFactor{ + this, "LabelTextSizeFactor", _("Candidate Label Text Size Factor"), 100, + IntConstrain(0, 400)}; + Option> candidateLabelColor{ + this, "CandidateLabelColor", _("Candidate Label Text Color")}; + Option> highlightCandidateLabelColor{ + this, "HighlightCandidateLabelColor", + _("Highlight Candidate Label Text Color")}; + Option commentTextSizeFactor{ + this, "CommentTextSizeFactor", _("Candidate Comment Text Size Factor"), + 75, IntConstrain(0, 400)}; + Option> candidateCommentColor{ + this, "CandidateCommentColor", _("Candidate Comment Text Color")}; + Option> highlightCandidateCommentColor{ + this, "HighlightCandidateCommentColor", + _("Highlight Candidate Comment Text Color")}; + // Material 3 style. When SurfaceColor is fully transparent the panel falls + // back to the classic image based background / highlight. + Option surfaceColor{this, "SurfaceColor", + _("Material panel surface color"), + Color("#00000000")}; + Option outlineColor{this, "OutlineColor", + _("Material panel outline color"), + Color("#00000000")}; + Option highlightFillColor{this, "HighlightFillColor", + _("Material highlight fill color"), + Color("#00000000")}; + Option hoverFillColor{this, "HoverFillColor", + _("Material hover state layer color"), + Color("#00000000")}; + Option panelRadius{this, "PanelRadius", + _("Panel corner radius"), 0, + IntConstrain(0, 64)}; + Option itemRadius{this, "ItemRadius", + _("Candidate corner radius"), 0, + IntConstrain(0, 64)}; + Option enableBlur{this, "EnableBlur", _("Enable Blur on KWin"), + false}; + Option blurMask{this, "BlurMask", _("Blur mask"), ""}; + Option blurMargin{this, "BlurMargin", _("Blur Margin")}; + Option fullWidthHighlight{ + this, "FullWidthHighlight", + _("Use all horizontal space for highlight when it is vertical list"), + true}; + OptionWithAnnotation + buttonAlignment{this, "PageButtonAlignment", + _("Page button vertical alignment"), + PageButtonAlignment::Bottom}; + Option background{this, "Background", + _("Background")}; + Option highlight{this, "Highlight", + _("Highlight Background")}; + Option contentMargin{this, "ContentMargin", + _("Margin around all content")}; + Option textMargin{this, "TextMargin", + _("Margin around text")}; + Option prev{this, "PrevPage", _("Prev Page Button")}; + Option next{this, "NextPage", _("Next Page Button")}; + Option shadowMargin{this, "ShadowMargin", + _("Shadow Margin")};); +FCITX_CONFIGURATION( + MenuThemeConfig, + Option normalColor{this, "NormalColor", _("Normal text color"), + Color("#000000ff")}; + Option highlightTextColor{this, "HighlightCandidateColor", + _("Selected Item text color"), + Color("#ffffffff")}; + Option spacing{this, "Spacing", _("Spacing"), 0}; + Option enableBlur{this, "EnableBlur", _("Enable Blur on KWin"), + false}; + Option blurMask{this, "BlurMask", _("Blur mask"), ""}; + Option blurMargin{this, "BlurMargin", _("Blur Margin")}; + Option background{this, "Background", + _("Background")}; + Option highlight{this, "Highlight", + _("Highlight Background")}; + Option separator{this, "Separator", + _("Separator Background")}; + Option checkBox{this, "CheckBox", _("Check box")}; + Option subMenu{this, "SubMenu", _("Sub Menu")}; + Option contentMargin{this, "ContentMargin", + _("Margin around all content")}; + Option textMargin{this, "TextMargin", + _("Margin around text")};); + +FCITX_CONFIGURATION(ThemeMetadata, + Option name{this, "Name", _("Name")}; + Option version{this, "Version", _("Version"), 1}; + Option author{this, "Author", _("Author")}; + Option description{this, "Description", + _("Description")};) + +FCITX_CONFIGURATION( + ThemeConfig, + HiddenOption metadata{this, "Metadata", _("Metadata")}; + Option supportedScale{this, "SupportedScale", + _("Supported image scale"), 1, + IntConstrain(1, 10)}; + Option inputPanel{this, "InputPanel", + _("Input Panel")}; + Option menu{this, "Menu", _("Menu")}; + Option> accentColor{this, "AccentColorField", + _("Accent Colors")};); + +class M3UI; +class ClassicUIConfig; +class Theme; + +class ThemeImage { +public: + using Pixmap = UniqueCPtr; + using CairoPattern = UniqueCPtr; + enum class TilePosition { + TopLeft, + Top, + TopRight, + Left, + Center, + Right, + BottomLeft, + Bottom, + BottomRight, + }; + struct Svg { + int width = 0; + int height = 0; + CairoPattern pattern; + }; + struct Pattern { + int width = 0; + int height = 0; + int borderWidth = 0; + CairoPattern pattern; + }; + struct NineTiles { + struct Tile { + int width = 0; + int height = 0; + int sourceX = 0; + int sourceY = 0; + CairoPattern pattern; + }; + int width = 0; + int height = 0; + std::array tiles; + }; + + ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg, + const Color &color, const Color &borderColor); + ThemeImage(const Theme &theme, const ActionImageConfig &cfg); + ThemeImage(const IconTheme &iconTheme, const std::string &icon, + const std::string &label, uint32_t size, + const M3UI *classicui); + + static void drawTextIcon(cairo_surface_t *surface, const std::string &label, + uint32_t size, const ClassicUIConfig &config); + + auto height() const { return image_ ? image_->height : 0; } + auto width() const { return image_ ? image_->width : 0; } + + auto size() const { return size_; } + + bool valid() const { return image_.has_value(); } + bool hasOverlay() const { return overlay_.has_value(); } + auto overlayWidth() const { return overlay_ ? overlay_->width : 0; } + auto overlayHeight() const { return overlay_ ? overlay_->height : 0; } + bool isPattern() const { return isPattern_; } + void paintRegion(cairo_t *c, TilePosition tile, double destX, double destY, + double destWidth, double destHeight, double alpha = 1.0, + bool overlay = false) const; + +private: + std::string currentText_; + uint32_t size_ = 0; + bool isImage_ = false; + + std::optional image_; + std::optional overlay_; + std::map scaledImages_; + std::map scaledOverlays_; + bool isPattern_ = false; +}; + +class Theme : public ThemeConfig { +public: + Theme(); + ~Theme(); + + const std::string &name() const { return name_; } + void load(std::string_view name); + void load(std::string_view name, const RawConfig &rawConfig); + const ThemeImage &loadImage(const std::string &icon, + const std::string &label, uint32_t size, + const M3UI *classicui); + const ThemeImage &loadBackground(const BackgroundImageConfig &cfg); + const ThemeImage &loadAction(const ActionImageConfig &cfg); + + void paint(cairo_t *c, const BackgroundImageConfig &cfg, double dx, + double dy, double width, double height, double alpha); + + void paint(cairo_t *c, const ActionImageConfig &cfg, double alpha = 1.0); + + std::vector mask(const BackgroundImageConfig &cfg, int width, + int height); + + bool setIconTheme(const std::string &name); + + const auto &maskConfig() const { return maskConfig_; } + const auto &accentColorFields() const { return accentColorFields_; } + + void populateColor(std::optional accent); + + const auto &inputPanelBackground() const { return inputPanelBackground_; } + const auto &inputPanelBorder() const { return inputPanelBorder_; } + const auto &inputPanelHighlightCandidateBackground() const { + return inputPanelHighlightCandidateBackground_; + } + const auto &inputPanelHighlightCandidateBorder() const { + return inputPanelHighlightCandidateBorder_; + } + const auto &inputPanelHighlight() const { return inputPanelHighlight_; } + const auto &inputPanelText() const { return inputPanelText_; } + const auto &inputPanelHighlightText() const { + return inputPanelHighlightText_; + } + const auto &inputPanelHighlightCandidateText() const { + return inputPanelHighlightCandidateText_; + } + const auto &inputPanelCandidateLabelText() const { + return inputPanelCandidateLabelText_; + } + const auto &inputPanelHighlightCandidateLabelText() const { + return inputPanelHighlightCandidateLabelText_; + } + const auto &inputPanelCandidateCommentText() const { + return inputPanelCandidateCommentText_; + } + const auto &inputPanelHighlightCandidateCommentText() const { + return inputPanelHighlightCandidateCommentText_; + } + + const auto &menuBackground() const { return menuBackground_; } + const auto &menuBorder() const { return menuBorder_; } + const auto &menuSelectedItemBackground() const { + return menuSelectedItemBackground_; + } + const auto &menuSelectedItemBorder() const { + return menuSelectedItemBorder_; + } + const auto &menuSeparator() const { return menuSeparator_; } + const auto &menuText() const { return menuText_; } + const auto &menuSelectedItemText() const { return menuSelectedItemText_; } + const auto &menuBlurMaskConfig() const { return menuBlurMaskConfig_; } + + bool isSystemTheme() const { return isSystemTheme_; } + + static bool isSystemThemeName(std::string_view themeName); + +private: + void reset(); + + std::unordered_map + backgroundImageTable_; + std::unordered_map actionImageTable_; + std::unordered_map trayImageTable_; + IconTheme iconTheme_; + std::string name_; + BackgroundImageConfig maskConfig_; + BackgroundImageConfig menuBlurMaskConfig_; + std::unordered_set accentColorFields_; + + Color inputPanelBackground_; + Color inputPanelBorder_; + Color inputPanelHighlightCandidateBackground_; + Color inputPanelHighlightCandidateBorder_; + Color inputPanelHighlight_; + Color inputPanelText_; + Color inputPanelHighlightText_; + Color inputPanelHighlightCandidateText_; + + Color inputPanelCandidateLabelText_; + Color inputPanelHighlightCandidateLabelText_; + Color inputPanelCandidateCommentText_; + Color inputPanelHighlightCandidateCommentText_; + + Color menuBackground_; + Color menuBorder_; + Color menuSelectedItemBackground_; + Color menuSelectedItemBorder_; + Color menuSeparator_; + Color menuText_; + Color menuSelectedItemText_; + + bool isSystemTheme_ = false; +}; + +inline void cairoSetSourceColor(cairo_t *cr, const Color &color) { + cairo_set_source_rgba(cr, color.redF(), color.greenF(), color.blueF(), + color.alphaF()); +} + +inline void shrink(Rect &rect, const MarginConfig &margin) { + int newWidth = rect.width() - *margin.marginLeft - *margin.marginRight; + int newHeight = rect.height() - *margin.marginTop - *margin.marginBottom; + newWidth = std::max(newWidth, 0); + newHeight = std::max(newHeight, 0); + rect.setPosition(rect.left() + *margin.marginLeft, + rect.top() + *margin.marginTop); + rect.setSize(newWidth, newHeight); +} + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_THEME_H_ diff --git a/src/waylandcursor.cpp b/src/waylandcursor.cpp new file mode 100644 index 0000000..58620ae --- /dev/null +++ b/src/waylandcursor.cpp @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "waylandcursor.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/event.h" +#include "fcitx-utils/eventloopinterface.h" +#include "display.h" +#include "waylandpointer.h" +#include "waylandui.h" +#include "wl_callback.h" +#include "wl_compositor.h" +#include "wl_output.h" +#include "wl_surface.h" +#include "wp_cursor_shape_manager_v1.h" + +namespace fcitx::m3ui { + +WaylandCursor::WaylandCursor(WaylandPointer *pointer) + : pointer_(pointer), animationStart_(now(CLOCK_MONOTONIC)) { + auto *display = pointer->ui()->display(); + time_ = pointer->ui()->parent()->instance()->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, 0, 1000, [this](EventSourceTime *, uint64_t) { + timerCallback(); + return true; + }); + time_->setEnabled(false); + display->requestGlobals(); + cursorShapeGlobalConn_ = display->globalCreated().connect( + [this](const std::string &name, const std::shared_ptr &) { + if (name == wayland::WpCursorShapeManagerV1::interface) { + setupCursorShape(); + } + }); + setupCursorShape(); +} + +void WaylandCursor::setupCursorShape() { + auto cursorShape = + pointer_->ui()->display()->getGlobal(); + if (!cursorShape) { + return; + } + cursorShape_.reset(cursorShape->getPointer(pointer_->pointer())); +} + +wayland::WlSurface *WaylandCursor::getOrCreateSurface() { + if (!surface_) { + auto compositor = + pointer_->ui()->display()->getGlobal(); + surface_.reset(compositor->createSurface()); + surface_->enter().connect([this](wayland::WlOutput *output) { + const auto *info = + pointer_->ui()->display()->outputInformation(output); + if (!info) { + return; + } + if (outputScale_ != info->scale()) { + outputScale_ = info->scale(); + update(); + } + }); + } + return surface_.get(); +} + +int32_t WaylandCursor::scale() { + auto outputs = pointer_->ui()->display()->getGlobals(); + int32_t scale = 1; + if (outputScale_) { + return *outputScale_; + } + for (const auto &output : outputs) { + const auto *info = + pointer_->ui()->display()->outputInformation(output.get()); + if (!info) { + continue; + } + scale = std::max(scale, info->scale()); + } + return scale; +} + +void WaylandCursor::update() { + if (!pointer_->enterSerial()) { + return; + } + if (cursorShape_) { + cursorShape_->setShape(pointer_->enterSerial(), + WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT); + surface_.reset(); + return; + } + auto info = pointer_->ui()->cursorTheme()->loadCursorTheme(scale()); + auto *surface = getOrCreateSurface(); + if (info.theme != theme_) { + surface->attach(nullptr, 0, 0); + surface->commit(); + theme_ = info.theme; + } + auto *cursor = info.cursor; + if (!cursor) { + return; + } + uint32_t duration = 0; + int frame = wl_cursor_frame_and_duration( + cursor, (now(CLOCK_MONOTONIC) - animationStart_) / 1000, &duration); + + auto bufferScale = scale(); + + int pixelSize = std::max( + cursor->images[frame]->width, + cursor->images[frame]->height); // Not all cursor themes are square + while (bufferScale > 1 && pixelSize / bufferScale < + pointer_->ui()->cursorTheme()->cursorSize()) { + --bufferScale; + } + + // Do not attach width/height that is not divisible by scale. + if (cursor->images[frame]->width % bufferScale != 0 || + cursor->images[frame]->height % bufferScale != 0) { + return; + } + + pointer_->pointer()->setCursor( + pointer_->enterSerial(), surface, + cursor->images[frame]->hotspot_x / bufferScale, + cursor->images[frame]->hotspot_y / bufferScale); + surface->setBufferScale(bufferScale); + + frameCalled_ = false; + timerCalled_ = false; + wl_surface_attach(*surface, + wl_cursor_image_get_buffer(cursor->images[frame]), 0, 0); + surface->damage(0, 0, cursor->images[frame]->width, + cursor->images[frame]->height); + + std::unique_ptr newCallback; + if (duration) { + newCallback.reset(surface->frame()); + time_->setOneShot(); + time_->setNextInterval(duration * 1000ULL); + newCallback->done().connect([this](uint32_t) { frameCallback(); }); + } + + surface->commit(); + // Ensure callback_ is the last variable being destroyed. + callback_ = std::move(newCallback); +} + +void WaylandCursor::timerCallback() { + timerCalled_ = true; + maybeUpdate(); +} + +void WaylandCursor::frameCallback() { + frameCalled_ = true; + maybeUpdate(); +} + +void WaylandCursor::maybeUpdate() { + if (frameCalled_ && timerCalled_) { + update(); + } +} + +} // namespace fcitx::m3ui diff --git a/src/waylandcursor.h b/src/waylandcursor.h new file mode 100644 index 0000000..e4d9353 --- /dev/null +++ b/src/waylandcursor.h @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDCURSOR_H_ +#define _FCITX_UI_CLASSIC_WAYLANDCURSOR_H_ + +#include +#include +#include +#include "fcitx-utils/event.h" +#include "fcitx-utils/signals.h" +#include "display.h" +#include "wl_callback.h" +#include "wl_surface.h" +#include "wp_cursor_shape_device_v1.h" + +namespace fcitx::m3ui { + +class WaylandPointer; + +class WaylandCursor { +public: + WaylandCursor(WaylandPointer *pointer); + + // Size and hotspot are in surface coordinates + void update(); + +private: + int32_t scale(); + void timerCallback(); + void frameCallback(); + void maybeUpdate(); + void setupCursorShape(); + wayland::WlSurface *getOrCreateSurface(); + WaylandPointer *pointer_; + uint64_t animationStart_; + + // We keep a reference to the + std::shared_ptr theme_; + std::unique_ptr surface_; + std::unique_ptr callback_; + std::unique_ptr time_; + bool timerCalled_ = false; + bool frameCalled_ = false; + std::optional outputScale_; + ScopedConnection cursorShapeGlobalConn_; + std::unique_ptr cursorShape_; +}; + +} // namespace fcitx::m3ui + +#endif \ No newline at end of file diff --git a/src/waylandcursortheme.cpp b/src/waylandcursortheme.cpp new file mode 100644 index 0000000..f6004db --- /dev/null +++ b/src/waylandcursortheme.cpp @@ -0,0 +1,105 @@ +#include "waylandcursortheme.h" +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/environ.h" +#include "fcitx-utils/misc_p.h" +#include "waylandui.h" +#include "wl_shm.h" + +#ifdef ENABLE_DBUS +#include "fcitx-utils/dbus/variant.h" +#include "dbus_public.h" +#include "portalsettingmonitor.h" +#endif + +namespace fcitx::m3ui { + +WaylandCursorTheme::WaylandCursorTheme(WaylandUI *ui) + : shm_(ui->display()->getGlobal()) { + + if (auto size = getEnvironment("XCURSOR_SIZE")) { + unsigned int cursorSize = 0; + if (std::from_chars(size->data(), size->data() + size->size(), + cursorSize) + .ec == std::errc()) { + setCursorSize(cursorSize); + } + } + + if (auto theme = getEnvironment("XCURSOR_THEME")) { + setTheme(*theme); + } else { + setTheme({}); + } + +#ifdef ENABLE_DBUS + if (auto *dbusAddon = ui->parent()->dbus()) { + settingMonitor_ = std::make_unique( + *dbusAddon->call()); + cursorSizeWatcher_ = + settingMonitor_->watch("org.gnome.desktop.interface", "cursor-size", + [this](const dbus::Variant &value) { + if (value.signature() == "i") { + setCursorSize(value.dataAs()); + } + }); + cursorThemeWatcher_ = settingMonitor_->watch( + "org.gnome.desktop.interface", "cursor-theme", + [this](const dbus::Variant &value) { + if (value.signature() == "s") { + setTheme(value.dataAs()); + } + }); + } +#endif +} + +void WaylandCursorTheme::setCursorSize(int cursorSize) { + // Add some simple validation. + int newCursorSize = 24; + if (cursorSize > 0 && cursorSize < 2048) { + newCursorSize = cursorSize; + } + + if (newCursorSize == cursorSize_) { + return; + } + cursorSize_ = newCursorSize; + themes_.clear(); + themeChangedSignal_(); +} + +void WaylandCursorTheme::setTheme(const std::string &theme) { + themes_.clear(); + themeName_ = theme; + themeChangedSignal_(); +} + +WaylandCursorInfo WaylandCursorTheme::loadCursorTheme(int scale) { + auto size = cursorSize_ * scale; + if (auto *theme = findValue(themes_, size)) { + return *theme; + } + WaylandCursorInfo info; + info.theme = std::shared_ptr( + wl_cursor_theme_load(themeName_.empty() ? nullptr : themeName_.data(), + size, *shm_), + wl_cursor_theme_destroy); + if (info.theme) { + info.cursor = wl_cursor_theme_get_cursor(info.theme.get(), "default"); + if (!info.cursor) { + info.cursor = + wl_cursor_theme_get_cursor(info.theme.get(), "left_ptr"); + } + } + + auto &themePtr = themes_[size] = std::move(info); + return themePtr; +} + +} // namespace fcitx::m3ui diff --git a/src/waylandcursortheme.h b/src/waylandcursortheme.h new file mode 100644 index 0000000..6a20d27 --- /dev/null +++ b/src/waylandcursortheme.h @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2023-2023 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDCURSORTHEME_H_ +#define _FCITX_UI_CLASSIC_WAYLANDCURSORTHEME_H_ + +#include +#include +#include +#include +#include "fcitx-utils/signals.h" +#include "config.h" +#include "portalsettingmonitor.h" +#include "wl_shm.h" + +namespace fcitx::m3ui { +class WaylandUI; + +struct WaylandCursorInfo { + std::shared_ptr theme; + wl_cursor *cursor = nullptr; +}; + +class WaylandCursorTheme { +public: + WaylandCursorTheme(WaylandUI *ui); + void setTheme(const std::string &theme); + + auto &themeChanged() const { return themeChangedSignal_; } + + WaylandCursorInfo loadCursorTheme(int scale); + auto cursorSize() const { return cursorSize_; } + +private: + void setCursorSize(int cursorSize); + void timerCallback(); + void frameCallback(); + + fcitx::Signal themeChangedSignal_; + + std::shared_ptr shm_; + // Size to theme map; + std::unordered_map themes_; + int cursorSize_ = 24; + std::string themeName_; +#ifdef ENABLE_DBUS + std::unique_ptr settingMonitor_; + std::unique_ptr cursorSizeWatcher_, cursorThemeWatcher_; +#endif +}; + +} // namespace fcitx::m3ui + +#endif \ No newline at end of file diff --git a/src/waylandinputwindow.cpp b/src/waylandinputwindow.cpp new file mode 100644 index 0000000..2f39833 --- /dev/null +++ b/src/waylandinputwindow.cpp @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "waylandinputwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/rect.h" +#include "fcitx/inputcontext.h" +#include "common.h" +#include "ext_background_effect_manager_v1.h" +#include "inputwindow.h" +#include "theme.h" +#include "waylandim_public.h" +#include "waylandui.h" +#include "waylandwindow.h" +#include "wl_compositor.h" +#include "wl_region.h" +#include "zwp_input_method_v2.h" +#include "zwp_input_panel_v1.h" + +#ifdef __linux__ +#include +#elif __FreeBSD__ +#include +#else +#define BTN_LEFT 0x110 +#define BTN_RIGHT 0x111 +#endif + +namespace fcitx::m3ui { + +WaylandInputWindow::WaylandInputWindow(WaylandUI *ui) + : InputWindow(ui->parent()), ui_(ui), window_(ui->newWindow()) { + window_->createWindow(); + window_->repaint().connect([this]() { + if (auto *ic = repaintIC_.get()) { + if (ic->hasFocus()) { + update(ic); + } + } + }); + window_->click().connect([this](int x, int y, uint32_t button, + uint32_t state) { + if (state != WL_POINTER_BUTTON_STATE_PRESSED) { + return; + } + if (button == BTN_LEFT) { + click(x, y); + } else if (button == BTN_RIGHT) { + rightClick(x, y); + } + }); + window_->hover().connect([this](int x, int y) { + if (hover(x, y)) { + repaint(); + } + }); + window_->leave().connect([this]() { + if (hover(-1, -1)) { + repaint(); + } + }); + window_->touchDown().connect([this](int x, int y) { click(x, y); }); + window_->touchUp().connect([](int, int) { + // do nothing + }); + window_->axis().connect([this](int, int, uint32_t axis, wl_fixed_t value) { + if (axis != WL_POINTER_AXIS_VERTICAL_SCROLL) { + return; + } + scroll_ += value; + bool triggered = false; + while (scroll_ >= 2560) { + scroll_ -= 2560; + wheel(/*up=*/false); + triggered = true; + } + while (scroll_ <= -2560) { + scroll_ += 2560; + wheel(/*up=*/true); + triggered = true; + } + if (triggered) { + repaint(); + } + }); + initPanel(); +} + +void WaylandInputWindow::initPanel() { + if (!window_->surface()) { + window_->createWindow(); + updateBlur(); + } + + setFontDPI(*parent_->config().forceWaylandDPI); +} + +void WaylandInputWindow::setBlurManager( + std::shared_ptr blur) { + blurManager_ = std::move(blur); + updateBlur(); +} + +void WaylandInputWindow::updateBlur() { + if (!window_->surface()) { + return; + } + blur_.reset(); + if (!blurManager_) { + return; + } + + auto compositor = ui_->display()->getGlobal(); + if (!compositor) { + return; + } + auto width = window_->width(); + auto height = window_->height(); + Rect rect(0, 0, width, height); + shrink(rect, *ui_->parent()->theme().inputPanel->blurMargin); + if (!*ui_->parent()->theme().inputPanel->enableBlur || rect.isEmpty()) { + return; + } + std::vector data; + std::unique_ptr region(compositor->createRegion()); + if (ui_->parent()->theme().inputPanel->blurMask->empty()) { + region->add(rect.left(), rect.top(), rect.width(), rect.height()); + } else { + auto regions = + parent_->theme().mask(parent_->theme().maskConfig(), width, height); + for (const auto &rect : regions) { + region->add(rect.left(), rect.top(), rect.width(), rect.height()); + } + } + blur_.reset(blurManager_->getBackgroundEffect(window_->surface())); + blur_->setBlurRegion(region.get()); +} + +void WaylandInputWindow::updateScale() { window_->updateScale(); } + +void WaylandInputWindow::resetPanel() { panelSurface_.reset(); } + +void WaylandInputWindow::update(fcitx::InputContext *ic) { + const auto oldVisible = visible(); + auto [width, height] = InputWindow::update(ic); + CLASSICUI_DEBUG() << "Wayland Input Window visible:" << visible() + << " for IC program:" + << (ic ? ic->program() : std::string("-")) << " frontend:" + << (ic ? ic->frontendName() : std::string("-")); + if (!oldVisible && !visible()) { + CLASSICUI_DEBUG() << "Wayland Input Window has been hidden."; + return; + } + + if (!visible()) { + CLASSICUI_DEBUG() << "Hide Wayland Input Window."; + hoverIndex_ = -1; + window_->hide(); + repaintIC_.unwatch(); + panelSurface_.reset(); + panelSurfaceV2_.reset(); + blur_.reset(); + window_->destroyWindow(); + return; + } + + assert(!visible() || ic != nullptr); + + CLASSICUI_DEBUG() + << "Wayland Input Window is visible, ensure surface is created."; + initPanel(); + if (ic->frontendName() == "wayland_v2") { + if (!panelSurfaceV2_ || ic != v2IC_.get()) { + auto *waylandim = ui_->parent()->waylandim(); + if (!waylandim) { + CLASSICUI_ERROR() + << "Failed to request waylandim addon, this should not " + "happen since we have wayland_v2 input context."; + return; + } + auto *im = waylandim->call(ic); + if (!im) { + CLASSICUI_ERROR() + << "Failed to request get zwp_input_method_v2 object, this " + "should not happen since we have wayland_v2 input " + "context."; + return; + } + v2IC_ = ic->watch(); + panelSurfaceV2_.reset(); + panelSurfaceV2_.reset(im->getInputPopupSurface(window_->surface())); + } + } else if (ic->frontendName() == "wayland") { + auto panel = ui_->display()->getGlobal(); + if (!panel) { + return; + } + if (!panelSurface_) { + panelSurface_.reset( + panel->getInputPanelSurface(window_->surface())); + panelSurface_->setOverlayPanel(); + } + } + if (!panelSurface_ && !panelSurfaceV2_) { + CLASSICUI_DEBUG() << "No Panel surface available, return."; + return; + } + + if (width != window_->width() || height != window_->height()) { + window_->resize(width, height); + updateBlur(); + } + + repaint(); + repaintIC_ = ic->watch(); +} + +void WaylandInputWindow::repaint() { + if (!visible()) { + return; + } + + if (auto *surface = window_->prerender()) { + cairo_t *c = cairo_create(surface); + auto surfaceWidth = cairo_image_surface_get_width(surface); + auto surfaceHeight = cairo_image_surface_get_height(surface); + cairo_surface_set_device_scale( + cairo_get_target(c), + window_->bufferScale() / WaylandWindow::ScaleDominatorF, + window_->bufferScale() / WaylandWindow::ScaleDominatorF); + paint(c, + surfaceWidth * WaylandWindow::ScaleDominatorF / + window_->bufferScale(), + surfaceHeight * WaylandWindow::ScaleDominatorF / + window_->bufferScale()); + cairo_destroy(c); + window_->render(); + } else if (showAnimating_ || highlightAnimating_) { + // No free buffer for this frame, try again on the next one. + scheduleFrame(); + } +} + +} // namespace fcitx::m3ui diff --git a/src/waylandinputwindow.h b/src/waylandinputwindow.h new file mode 100644 index 0000000..9994ec2 --- /dev/null +++ b/src/waylandinputwindow.h @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDINPUTWINDOW_H_ +#define _FCITX_UI_CLASSIC_WAYLANDINPUTWINDOW_H_ + +#include +#include +#include "fcitx-utils/trackableobject.h" +#include "fcitx/inputcontext.h" +#include "ext_background_effect_manager_v1.h" +#include "ext_background_effect_surface_v1.h" +#include "inputwindow.h" +#include "zwp_input_panel_surface_v1.h" +#include "zwp_input_popup_surface_v2.h" + +namespace fcitx::m3ui { + +class WaylandUI; +class WaylandWindow; + +class WaylandInputWindow : public InputWindow { +public: + WaylandInputWindow(WaylandUI *ui); + + void initPanel(); + void resetPanel(); + void update(InputContext *ic); + void repaint(); + void + setBlurManager(std::shared_ptr blur); + void updateScale(); + +private: + void updateBlur(); + + WaylandUI *ui_; + wl_fixed_t scroll_ = 0; + std::unique_ptr panelSurface_; + TrackableObjectReference v2IC_; + std::unique_ptr panelSurfaceV2_; + std::unique_ptr window_; + TrackableObjectReference repaintIC_; + std::shared_ptr blurManager_; + std::unique_ptr blur_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WAYLANDINPUTWINDOW_H_ diff --git a/src/waylandpointer.cpp b/src/waylandpointer.cpp new file mode 100644 index 0000000..11e338a --- /dev/null +++ b/src/waylandpointer.cpp @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2020-2020 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "waylandpointer.h" +#include +#include +#include "waylandwindow.h" +#include "wl_seat.h" +#include "wl_surface.h" + +namespace fcitx::m3ui { + +WaylandPointer::WaylandPointer(WaylandUI *ui, wayland::WlSeat *seat) + : ui_(ui), display_(ui_->display()) { + capConn_ = seat->capabilities().connect([this, seat](uint32_t caps) { + if ((caps & WL_SEAT_CAPABILITY_POINTER) && !pointer_) { + pointer_.reset(seat->getPointer()); + initPointer(); + } else if (!(caps & WL_SEAT_CAPABILITY_POINTER) && pointer_) { + pointer_.reset(); + } + + if ((caps & WL_SEAT_CAPABILITY_TOUCH) && !touch_) { + touch_.reset(seat->getTouch()); + initTouch(); + } else if (!(caps & WL_SEAT_CAPABILITY_TOUCH) && touch_) { + touch_.reset(); + } + }); +} + +void WaylandPointer::initPointer() { + pointer_->enter().connect([this](uint32_t serial, + wayland::WlSurface *surface, wl_fixed_t sx, + wl_fixed_t sy) { + enterSerial_ = serial; + cursor()->update(); + auto *window = static_cast(surface->userData()); + if (!window) { + return; + } + pointerFocus_ = window->watch(); + pointerFocusX_ = wl_fixed_to_int(sx); + pointerFocusY_ = wl_fixed_to_int(sy); + }); + pointer_->leave().connect([this](uint32_t, wayland::WlSurface *surface) { + if (auto *window = pointerFocus_.get()) { + if (window->surface() == surface) { + pointerFocus_.unwatch(); + window->leave()(); + } + } + }); + pointer_->motion().connect([this](uint32_t, wl_fixed_t sx, wl_fixed_t sy) { + if (auto *window = pointerFocus_.get()) { + pointerFocusX_ = wl_fixed_to_int(sx); + pointerFocusY_ = wl_fixed_to_int(sy); + window->hover()(pointerFocusX_, pointerFocusY_); + } + }); + pointer_->button().connect( + [this](uint32_t, uint32_t, uint32_t button, uint32_t state) { + if (auto *window = pointerFocus_.get()) { + window->click()(pointerFocusX_, pointerFocusY_, button, state); + } + }); + pointer_->axis().connect([this](uint32_t, uint32_t axis, wl_fixed_t value) { + if (auto *window = pointerFocus_.get()) { + window->axis()(pointerFocusX_, pointerFocusY_, axis, value); + } + }); +} + +void WaylandPointer::initTouch() { + touch_->down().connect([this](uint32_t, uint32_t, + wayland::WlSurface *surface, int, + wl_fixed_t sx, wl_fixed_t sy) { + // TODO handle id for multiple touch + auto *window = static_cast(surface->userData()); + if (!window) { + return; + } + touchFocus_ = window->watch(); + touchFocusX_ = wl_fixed_to_int(sx); + touchFocusY_ = wl_fixed_to_int(sy); + window->touchDown()(touchFocusX_, touchFocusY_); + }); + touch_->up().connect([this](uint32_t, uint32_t, int) { + // TODO handle id for multiple touch + if (auto *window = touchFocus_.get()) { + window->touchUp()(touchFocusX_, touchFocusY_); + touchFocus_.unwatch(); + window->leave()(); + } + }); +} + +} // namespace fcitx::m3ui diff --git a/src/waylandpointer.h b/src/waylandpointer.h new file mode 100644 index 0000000..b5c430b --- /dev/null +++ b/src/waylandpointer.h @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2020-2020 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDPOINTER_H_ +#define _FCITX_UI_CLASSIC_WAYLANDPOINTER_H_ + +#include +#include +#include "fcitx-utils/signals.h" +#include "fcitx-utils/trackableobject.h" +#include "display.h" +#include "waylandcursor.h" +#include "wl_pointer.h" +#include "wl_seat.h" +#include "wl_touch.h" + +namespace fcitx::m3ui { + +class WaylandUI; +class WaylandWindow; + +class WaylandPointer { +public: + WaylandPointer(WaylandUI *ui, wayland::WlSeat *seat); + + auto ui() const { return ui_; } + auto pointer() const { return pointer_.get(); } + auto enterSerial() const { return enterSerial_; } + + WaylandCursor *cursor() { + if (!cursor_) { + cursor_ = std::make_unique(this); + } + return cursor_.get(); + } + +private: + void initPointer(); + void initTouch(); + WaylandUI *ui_; + wayland::Display *display_; + std::unique_ptr pointer_; + TrackableObjectReference pointerFocus_; + int pointerFocusX_ = 0, pointerFocusY_ = 0; + std::unique_ptr touch_; + TrackableObjectReference touchFocus_; + int touchFocusX_ = 0, touchFocusY_ = 0; + ScopedConnection capConn_; + uint32_t enterSerial_ = 0; + std::unique_ptr cursor_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WAYLANDPOINTER_H_ diff --git a/src/waylandshmwindow.cpp b/src/waylandshmwindow.cpp new file mode 100644 index 0000000..c20843e --- /dev/null +++ b/src/waylandshmwindow.cpp @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "waylandshmwindow.h" +#include +#include +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "buffer.h" +#include "common.h" +#include "waylandui.h" +#include "waylandwindow.h" +#include "wl_shm.h" + +namespace fcitx::m3ui { + +namespace { + +void surfaceToBufferSize(unsigned int buffer_scale, uint32_t *width, + uint32_t *height) { + // round (size * scale), but all integer calculation. + *width = (*width * buffer_scale + (WaylandWindow::ScaleDominator / 2)) / + WaylandWindow::ScaleDominator; + *height = (*height * buffer_scale + (WaylandWindow::ScaleDominator / 2)) / + WaylandWindow::ScaleDominator; +} + +} // namespace + +WaylandShmWindow::WaylandShmWindow(WaylandUI *ui) + : WaylandWindow(ui), shm_(ui->display()->getGlobal()) {} + +WaylandShmWindow::~WaylandShmWindow() {} + +void WaylandShmWindow::destroyWindow() { + buffers_.clear(); + buffer_ = nullptr; + WaylandWindow::destroyWindow(); +} + +void WaylandShmWindow::newBuffer(uint32_t width, uint32_t height) { + if (!shm_) { + return; + } + buffers_.emplace_back(std::make_unique( + shm_.get(), width, height, WL_SHM_FORMAT_ARGB8888)); + buffers_.back()->rendered().connect([this]() { + // Use defer event here, otherwise repaint may delete buffer and cause + // problem. + deferEvent_ = ui_->parent()->instance()->eventLoop().addDeferEvent( + [this](EventSource *) { + if (pending_) { + pending_ = false; + + CLASSICUI_DEBUG() << "Trigger repaint"; + repaint_(); + } + deferEvent_.reset(); + return true; + }); + }); +} + +cairo_surface_t *WaylandShmWindow::prerender() { + // We use double buffer. + decltype(buffers_)::iterator iter; + for (iter = buffers_.begin(); iter != buffers_.end(); iter++) { + CLASSICUI_DEBUG() << "Buffer state: " << (*iter).get() << " " + << (*iter)->busy(); + if (!(*iter)->busy()) { + break; + } + } + + uint32_t bufferWidth = width_; + uint32_t bufferHeight = height_; + surfaceToBufferSize(bufferScale(), &bufferWidth, &bufferHeight); + + if (iter != buffers_.end() && ((*iter)->width() != bufferWidth || + (*iter)->height() != bufferHeight)) { + buffers_.erase(iter); + iter = buffers_.end(); + } + + if (iter == buffers_.end() && buffers_.size() < 2) { + newBuffer(bufferWidth, bufferHeight); + if (!buffers_.empty()) { + iter = std::prev(buffers_.end()); + } + } + + if (iter == buffers_.end()) { + CLASSICUI_DEBUG() << "Couldn't find avail buffer."; + pending_ = true; + // All buffers are busy. + buffer_ = nullptr; + return nullptr; + } + pending_ = false; + buffer_ = iter->get(); + + auto *cairoSurface = buffer_->cairoSurface(); + if (!cairoSurface) { + buffer_ = nullptr; + return nullptr; + } + return cairoSurface; +} + +void WaylandShmWindow::render() { + if (!buffer_) { + return; + } + + if (viewport_) { + if (buffer_->attachToSurface(surface_.get(), 1)) { + viewport_->setDestination(width_, height_); + surface_->commit(); + } + } else { + if (buffer_->attachToSurface(surface_.get(), lastOutputScale_)) { + surface_->commit(); + } + } +} + +void WaylandShmWindow::hide() { + if (!surface_) { + return; + } + surface_->attach(nullptr, 0, 0); + surface_->commit(); +} + +} // namespace fcitx::m3ui diff --git a/src/waylandshmwindow.h b/src/waylandshmwindow.h new file mode 100644 index 0000000..041caa1 --- /dev/null +++ b/src/waylandshmwindow.h @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDSHMWINDOW_H_ +#define _FCITX_UI_CLASSIC_WAYLANDSHMWINDOW_H_ + +#include +#include +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "buffer.h" +#include "waylandui.h" +#include "waylandwindow.h" + +namespace fcitx::m3ui { + +class WaylandShmWindow : public WaylandWindow { +public: + WaylandShmWindow(WaylandUI *ui); + ~WaylandShmWindow(); + + void destroyWindow(); + cairo_surface_t *prerender() override; + void render() override; + void hide() override; + +private: + void newBuffer(uint32_t width, uint32_t height); + + std::shared_ptr shm_; + std::vector> buffers_; + // Pointer to the current buffer. + wayland::Buffer *buffer_ = nullptr; + bool pending_ = false; + std::unique_ptr deferEvent_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WAYLANDSHMWINDOW_H_ diff --git a/src/waylandui.cpp b/src/waylandui.cpp new file mode 100644 index 0000000..2d21111 --- /dev/null +++ b/src/waylandui.cpp @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "waylandui.h" +#include +#include +#include "fcitx/userinterface.h" +#include "classicui.h" +#include "common.h" +#include "display.h" +#include "ext_background_effect_manager_v1.h" +#include "waylandcursortheme.h" +#include "waylandinputwindow.h" +#include "waylandpointer.h" +#include "waylandshmwindow.h" +#include "wl_compositor.h" +#include "wl_seat.h" +#include "wl_shm.h" +#include "wp_fractional_scale_manager_v1.h" +#include "wp_viewporter.h" +#include "zwp_input_panel_v1.h" + +namespace fcitx::m3ui { + +WaylandUI::WaylandUI(M3UI *parent, const std::string &name, + wl_display *display) + : UIInterface("wayland:" + name), parent_(parent), + display_( + static_cast(wl_display_get_user_data(display))) { + display_->requestGlobals(); + display_->requestGlobals(); + display_->requestGlobals(); + display_->requestGlobals(); + display_->requestGlobals(); + display_->requestGlobals(); + display_->requestGlobals(); + panelConn_ = display_->globalCreated().connect( + [this](const std::string &name, const std::shared_ptr &) { + if (name == wayland::ZwpInputPanelV1::interface) { + if (inputWindow_) { + inputWindow_->initPanel(); + } + } else if (name == wayland::WlCompositor::interface || + name == wayland::WlShm::interface) { + setupInputWindow(); + } else if (name == wayland::WlSeat::interface) { + auto seat = display_->getGlobal(); + if (seat) { + pointer_ = + std::make_unique(this, seat.get()); + } + } else if (name == + wayland::ExtBackgroundEffectManagerV1::interface) { + if (inputWindow_) { + inputWindow_->setBlurManager( + display_->getGlobal< + wayland::ExtBackgroundEffectManagerV1>()); + } + } else if (name == wayland::WpFractionalScaleManagerV1::interface || + name == wayland::WpViewporter::interface) { + if (inputWindow_) { + inputWindow_->updateScale(); + } + } + }); + panelRemovedConn_ = display_->globalRemoved().connect( + [this](const std::string &name, const std::shared_ptr &) { + if (name == wayland::ZwpInputPanelV1::interface) { + if (inputWindow_) { + inputWindow_->resetPanel(); + } + } else if (name == + wayland::ExtBackgroundEffectManagerV1::interface) { + if (inputWindow_) { + inputWindow_->setBlurManager(nullptr); + } + } else if (name == wayland::WpFractionalScaleManagerV1::interface || + name == wayland::WpViewporter::interface) { + if (inputWindow_) { + inputWindow_->updateScale(); + } + } + }); + auto seat = display_->getGlobal(); + if (seat) { + pointer_ = std::make_unique(this, seat.get()); + } + setupInputWindow(); +} + +WaylandUI::~WaylandUI() {} + +void WaylandUI::update(UserInterfaceComponent component, + InputContext *inputContext) { + if (inputWindow_ && component == UserInterfaceComponent::InputPanel) { + CLASSICUI_DEBUG() << "Update Wayland Input Window"; + inputWindow_->update(inputContext); + } +} + +void WaylandUI::suspend() { + if (!inputWindow_) { + return; + } + inputWindow_->update(nullptr); +} + +void WaylandUI::resume() { + CLASSICUI_DEBUG() << "Resume WaylandUI display name:" << display_; + CLASSICUI_DEBUG() << "Wayland Input window is initialized:" + << !!inputWindow_; +} + +void WaylandUI::setupInputWindow() { + if (inputWindow_) { + return; + } + + // Unable to draw window. + if (!display_->getGlobal()) { + return; + } + // Unable to create surface. + if (!display_->getGlobal()) { + return; + } + + cursorTheme_ = std::make_unique(this); + inputWindow_ = std::make_unique(this); + inputWindow_->initPanel(); + inputWindow_->setBlurManager( + display_->getGlobal()); +} + +std::unique_ptr WaylandUI::newWindow() { + return std::make_unique(this); +} +} // namespace fcitx::m3ui diff --git a/src/waylandui.h b/src/waylandui.h new file mode 100644 index 0000000..93b12d9 --- /dev/null +++ b/src/waylandui.h @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDUI_H_ +#define _FCITX_UI_CLASSIC_WAYLANDUI_H_ + +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/signals.h" +#include "fcitx/userinterface.h" +#include "classicui.h" +#include "display.h" +#include "waylandcursortheme.h" +#include "waylandpointer.h" + +namespace fcitx::m3ui { + +class WaylandWindow; +class WaylandInputWindow; + +class WaylandUI : public UIInterface { +public: + WaylandUI(M3UI *parent, const std::string &name, wl_display *display); + ~WaylandUI(); + + M3UI *parent() const { return parent_; } + wayland::Display *display() const { return display_; } + void update(UserInterfaceComponent component, + InputContext *inputContext) override; + void suspend() override; + void resume() override; + void setEnableTray(bool /*unused*/) override {} + + std::unique_ptr newWindow(); + + WaylandCursorTheme *cursorTheme() { return cursorTheme_.get(); } + +private: + void setupInputWindow(); + + M3UI *parent_; + wayland::Display *display_; + ScopedConnection panelConn_, panelRemovedConn_; + std::unique_ptr cursorTheme_; + std::unique_ptr pointer_; + std::unique_ptr inputWindow_; + std::unique_ptr defer_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WAYLANDUI_H_ diff --git a/src/waylandwindow.cpp b/src/waylandwindow.cpp new file mode 100644 index 0000000..e1cbb8a --- /dev/null +++ b/src/waylandwindow.cpp @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "waylandwindow.h" +#include +#include "fcitx-utils/eventloopinterface.h" +#include "waylandui.h" +#include "wl_compositor.h" +#include "wl_output.h" +#include "wl_surface.h" +#include "wp_fractional_scale_manager_v1.h" +#include "wp_viewporter.h" + +namespace fcitx::m3ui { + +WaylandWindow::WaylandWindow(WaylandUI *ui) : ui_(ui) {} + +WaylandWindow::~WaylandWindow() {} + +void WaylandWindow::createWindow() { + auto compositor = ui_->display()->getGlobal(); + if (!compositor) { + return; + } + surface_.reset(compositor->createSurface()); + surface_->setUserData(this); + updateScale(); + + enterConn_ = surface_->enter().connect([this](wayland::WlOutput *output) { + const auto *info = ui_->display()->outputInformation(output); + if (!info) { + return; + } + if (setScaleAndTransform(info->scale(), info->transform())) { + scheduleRepaint(); + } + }); +} + +void WaylandWindow::destroyWindow() { + resetFractionalScale(); + surface_.reset(); + enterConn_.disconnect(); +} + +void WaylandWindow::resetFractionalScale() { + viewport_.reset(); + fractionalScale_.reset(); + viewporter_.reset(); + fractionalScaleManager_.reset(); +} + +void WaylandWindow::scheduleRepaint() { + repaintEvent_ = ui_->parent()->instance()->eventLoop().addDeferEvent( + [this](EventSource *) { + repaint_(); + repaintEvent_.reset(); + return true; + }); +} + +void WaylandWindow::updateScale() { + if (!surface_) { + return; + } + + if (!*ui_->parent()->config().fractionalScale) { + resetFractionalScale(); + return; + } + + viewporter_ = ui_->display()->getGlobal(); + fractionalScaleManager_ = + ui_->display()->getGlobal(); + if (viewporter_ && fractionalScaleManager_) { + viewport_.reset(viewporter_->getViewport(surface_.get())); + fractionalScale_.reset( + fractionalScaleManager_->getFractionalScale(surface_.get())); + } + + if (viewport_ && fractionalScale_) { + fractionalScale_->preferredScale().connect([this](uint32_t scale) { + if (scale != lastFractionalScale_) { + lastFractionalScale_ = scale; + scheduleRepaint(); + } + }); + } else { + resetFractionalScale(); + } +} + +} // namespace fcitx::m3ui diff --git a/src/waylandwindow.h b/src/waylandwindow.h new file mode 100644 index 0000000..6395179 --- /dev/null +++ b/src/waylandwindow.h @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WAYLANDWINDOW_H_ +#define _FCITX_UI_CLASSIC_WAYLANDWINDOW_H_ + +#include +#include +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/signals.h" +#include "fcitx-utils/trackableobject.h" +#include "waylandui.h" +#include "window.h" +#include "wp_fractional_scale_manager_v1.h" +#include "wp_fractional_scale_v1.h" +#include "wp_viewport.h" +#include "wp_viewporter.h" + +namespace fcitx::m3ui { + +class WaylandWindow : public Window, public TrackableObject { +public: + static constexpr unsigned int ScaleDominator = 120; + static constexpr double ScaleDominatorF = ScaleDominator; + WaylandWindow(WaylandUI *ui); + ~WaylandWindow(); + + virtual void createWindow(); + void destroyWindow(); + virtual void hide() = 0; + + unsigned int bufferScale() const { + return viewport_ ? lastFractionalScale_ + : lastOutputScale_ * ScaleDominator; + } + int32_t outputScale() const { return lastOutputScale_; } + wl_output_transform transform() const { return transform_; } + bool setScaleAndTransform(int32_t scale, wl_output_transform transform) { + if (lastOutputScale_ != scale || transform_ != transform) { + lastOutputScale_ = scale; + transform_ = transform; + return true; + } + return false; + } + wayland::WlSurface *surface() { return surface_.get(); } + + auto &repaint() { return repaint_; } + auto &hover() { return hover_; } + auto &click() { return click_; } + auto &axis() { return axis_; } + auto &leave() { return leave_; } + + auto &touchDown() { return touchDown_; } + auto &touchUp() { return touchUp_; } + + void updateScale(); + +protected: + WaylandUI *ui_; + std::unique_ptr surface_; + fcitx::ScopedConnection enterConn_; + Signal repaint_; + Signal hover_; + Signal click_; + Signal axis_; + Signal leave_; + + Signal touchDown_; + Signal touchUp_; + + Rect serverAllocation_; + Rect allocation_; + + int32_t lastOutputScale_ = 1; + unsigned int lastFractionalScale_ = ScaleDominator; + wl_output_transform transform_ = WL_OUTPUT_TRANSFORM_NORMAL; + + std::shared_ptr viewporter_; + std::shared_ptr + fractionalScaleManager_; + + std::unique_ptr viewport_; + std::unique_ptr fractionalScale_; + std::unique_ptr repaintEvent_; + +private: + void resetFractionalScale(); + // Avoid repaint being done too much. + // For input window, since it will re-created frequently, the initial scale + // might be wrong. To avoid show blurry 1x on scaled monitor, we always use + // the last value. But just "upon" window is shown on screen, more than one + // new scale might arrive. Do not repaint upon every scale change. + void scheduleRepaint(); +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WAYLANDWINDOW_H_ diff --git a/src/window.cpp b/src/window.cpp new file mode 100644 index 0000000..3b254b9 --- /dev/null +++ b/src/window.cpp @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "window.h" + +namespace fcitx::m3ui { + +Window::Window() = default; + +void Window::resize(unsigned int width, unsigned int height) { + width_ = width; + height_ = height; +} +} // namespace fcitx::m3ui diff --git a/src/window.h b/src/window.h new file mode 100644 index 0000000..1d5fe56 --- /dev/null +++ b/src/window.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_WINDOW_H_ +#define _FCITX_UI_CLASSIC_WINDOW_H_ + +#include + +namespace fcitx::m3ui { + +class Window { +public: + Window(); + virtual ~Window() = default; + + int width() const { return width_; } + int height() const { return height_; } + virtual void resize(unsigned int width, unsigned int height); + + virtual cairo_surface_t *prerender() = 0; + virtual void render() = 0; + +protected: + unsigned int width_ = 100; + unsigned int height_ = 100; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_WINDOW_H_ diff --git a/src/xcbinputwindow.cpp b/src/xcbinputwindow.cpp new file mode 100644 index 0000000..5e139ad --- /dev/null +++ b/src/xcbinputwindow.cpp @@ -0,0 +1,315 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "xcbinputwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/rect.h" +#include "fcitx/inputcontext.h" +#include "inputwindow.h" +#include "theme.h" +#include "xcb_public.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +XCBInputWindow::XCBInputWindow(XCBUI *ui) + : XCBWindow(ui), InputWindow(ui->parent()), + atomBlur_(ui_->parent()->xcb()->call( + ui_->displayName(), "_KDE_NET_WM_BLUR_BEHIND_REGION", false)) {} + +void XCBInputWindow::postCreateWindow() { + if (ui_->ewmh()->_NET_WM_WINDOW_TYPE_COMBO && + ui_->ewmh()->_NET_WM_WINDOW_TYPE) { + xcb_ewmh_set_wm_window_type(ui_->ewmh(), wid_, 1, + &ui_->ewmh()->_NET_WM_WINDOW_TYPE_COMBO); + } + + if (ui_->ewmh()->_NET_WM_PID) { + xcb_ewmh_set_wm_pid(ui_->ewmh(), wid_, getpid()); + } + + const char name[] = "Fcitx5 Input Window"; + xcb_icccm_set_wm_name(ui_->connection(), wid_, XCB_ATOM_STRING, 8, + sizeof(name) - 1, name); + const char klass[] = "fcitx\0fcitx"; + xcb_icccm_set_wm_class(ui_->connection(), wid_, sizeof(klass) - 1, klass); + addEventMaskToWindow( + ui_->connection(), wid_, + XCB_EVENT_MASK_BUTTON_PRESS | XCB_EVENT_MASK_BUTTON_RELEASE | + XCB_EVENT_MASK_POINTER_MOTION | XCB_EVENT_MASK_EXPOSURE | + XCB_EVENT_MASK_LEAVE_WINDOW); +} + +const Rect *XCBInputWindow::getClosestScreen(const Rect &cursorRect) const { + const Rect *closestScreen = nullptr; + + int shortestDistance = INT_MAX; + for (const auto &rect : ui_->screenRects()) { + int thisDistance = + rect.first.distance(cursorRect.left(), cursorRect.top()); + if (thisDistance < shortestDistance) { + shortestDistance = thisDistance; + closestScreen = &rect.first; + } + } + + return closestScreen; +} + +int XCBInputWindow::calculatePositionX(const Rect &cursorRect, + const Rect *closestScreen) const { + // TODO: RTL support. + int x = cursorRect.left(); + + auto &theme = parent_->theme(); + int leftSW = + physicalFromLogical(theme.inputPanel->shadowMargin->marginLeft.value()); + int rightSW = physicalFromLogical( + theme.inputPanel->shadowMargin->marginRight.value()); + + int actualWidth = physicalWidth_ - leftSW - rightSW; + actualWidth = actualWidth <= 0 ? physicalWidth_ : actualWidth; + + if (closestScreen != nullptr) { + int newX = std::max(x, closestScreen->left()); + + if (newX + actualWidth > closestScreen->right()) { + newX = closestScreen->right() - actualWidth; + } + + x = std::max(newX, closestScreen->left()); + } + + // exclude shadow border width + x -= leftSW; + + return x; +} + +int XCBInputWindow::calculatePositionY(const Rect &cursorRect, + const Rect *closestScreen) const { + // TODO: RTL support. + int y = cursorRect.top(); + + auto &theme = parent_->theme(); + int topSW = + physicalFromLogical(theme.inputPanel->shadowMargin->marginTop.value()); + int bottomSW = physicalFromLogical( + theme.inputPanel->shadowMargin->marginBottom.value()); + + int actualHeight = physicalHeight_ - topSW - bottomSW; + actualHeight = actualHeight <= 0 ? physicalHeight_ : actualHeight; + + if (closestScreen != nullptr) { + int h = cursorRect.height(); + int newY; + if (y < closestScreen->top()) { + newY = closestScreen->top(); + } else { + newY = y + (h ? h : physicalFromLogical(10)); + } + + // Try flip y. + if (newY + actualHeight > closestScreen->bottom()) { + if (newY > closestScreen->bottom()) { + newY = closestScreen->bottom() - actualHeight - + physicalFromLogical(40); + } else { /* better position the window */ + newY = newY - actualHeight - + ((h == 0) ? physicalFromLogical(40) : h); + } + + // If after flip, top is out of the screen, we still prefer the top + // edge to be always with in screen. + if (newY < closestScreen->top()) { + newY = closestScreen->top(); + } + } + + y = newY; + } + + // exclude shadow border width + y -= topSW; + + return y; +} + +void XCBInputWindow::updatePosition(InputContext *inputContext) { + if (!visible()) { + return; + } + + const Rect &cursorRect = inputContext->cursorRect(); + const Rect *closestScreen = getClosestScreen(cursorRect); + xcb_params_configure_window_t wc; + wc.x = calculatePositionX(cursorRect, closestScreen); + wc.y = calculatePositionY(cursorRect, closestScreen); + wc.stack_mode = XCB_STACK_MODE_ABOVE; + xcb_aux_configure_window(ui_->connection(), wid_, + XCB_CONFIG_WINDOW_STACK_MODE | + XCB_CONFIG_WINDOW_X | XCB_CONFIG_WINDOW_Y, + &wc); +} + +void XCBInputWindow::updateDPI(InputContext *inputContext) { + auto dpi = ui_->dpiByPosition(inputContext->cursorRect().left(), + inputContext->cursorRect().top()); + setScale(scaleForDPI(dpi)); +} + +void XCBInputWindow::update(InputContext *inputContext) { + if (!wid_) { + return; + } + auto oldVisible = visible(); + if (inputContext) { + updateDPI(inputContext); + } + auto [width, height] = InputWindow::update(inputContext); + if (!visible()) { + if (oldVisible) { + xcb_unmap_window(ui_->connection(), wid_); + hoverIndex_ = -1; + } + return; + } + + auto oldPhysicalWidth = physicalWidth_; + auto oldPhysicalHeight = physicalHeight_; + resize(width, height); + if (physicalWidth_ != oldPhysicalWidth || + physicalHeight_ != oldPhysicalHeight) { + if (atomBlur_) { + Rect logicalRect(0, 0, width, height); + shrink(logicalRect, *ui_->parent()->theme().inputPanel->blurMargin); + if (!*ui_->parent()->theme().inputPanel->enableBlur || + logicalRect.isEmpty()) { + xcb_delete_property(ui_->connection(), wid_, atomBlur_); + } else { + auto rect = physicalFromLogical(logicalRect); + std::vector data; + if (ui_->parent()->theme().inputPanel->blurMask->empty()) { + data.push_back(rect.left()); + data.push_back(rect.top()); + data.push_back(rect.width()); + data.push_back(rect.height()); + xcb_change_property(ui_->connection(), + XCB_PROP_MODE_REPLACE, wid_, atomBlur_, + XCB_ATOM_CARDINAL, 32, data.size(), + data.data()); + } else { + auto region = parent_->theme().mask( + parent_->theme().maskConfig(), width, height); + for (const auto &rect : region) { + auto physicalRect = physicalFromLogical(rect); + data.push_back(physicalRect.left()); + data.push_back(physicalRect.top()); + data.push_back(physicalRect.width()); + data.push_back(physicalRect.height()); + } + xcb_change_property(ui_->connection(), + XCB_PROP_MODE_REPLACE, wid_, atomBlur_, + XCB_ATOM_CARDINAL, 32, data.size(), + data.data()); + } + } + } + } + + updatePosition(inputContext); + repaint(); + if (!oldVisible) { + xcb_map_window(ui_->connection(), wid_); + } +} + +bool XCBInputWindow::filterEvent(xcb_generic_event_t *event) { + uint8_t response_type = event->response_type & ~0x80; + switch (response_type) { + case XCB_EXPOSE: { + auto *expose = reinterpret_cast(event); + if (expose->window == wid_) { + repaint(); + return true; + } + break; + } + case XCB_BUTTON_PRESS: { + auto *buttonPress = reinterpret_cast(event); + if (buttonPress->event != wid_) { + break; + } + if (buttonPress->detail == XCB_BUTTON_INDEX_1) { + click(logicalFromPhysical(buttonPress->event_x), + logicalFromPhysical(buttonPress->event_y)); + } else if (buttonPress->detail == XCB_BUTTON_INDEX_3) { + rightClick(logicalFromPhysical(buttonPress->event_x), + logicalFromPhysical(buttonPress->event_y)); + } else if (buttonPress->detail == XCB_BUTTON_INDEX_4) { + wheel(/*up=*/true); + } else if (buttonPress->detail == XCB_BUTTON_INDEX_5) { + wheel(/*up=*/false); + } + return true; + } + case XCB_MOTION_NOTIFY: { + auto *motion = reinterpret_cast(event); + if (motion->event == wid_) { + if (hover(logicalFromPhysical(motion->event_x), + logicalFromPhysical(motion->event_y))) { + repaint(); + } + return true; + } + break; + } + case XCB_LEAVE_NOTIFY: { + auto *leave = reinterpret_cast(event); + if (leave->event == wid_) { + if (hover(-1, -1)) { + repaint(); + } + return true; + } + break; + } + default: + break; + } + return false; +} + +void XCBInputWindow::repaint() { + if (!visible()) { + return; + } + if (auto *surface = prerender()) { + cairo_t *c = cairo_create(surface); + auto surfaceWidth = cairo_image_surface_get_width(surface); + auto surfaceHeight = cairo_image_surface_get_height(surface); + paint(c, surfaceWidth / scale_, surfaceHeight / scale_); + cairo_destroy(c); + render(); + } else if (showAnimating_ || highlightAnimating_) { + // No free buffer for this frame, try again on the next one. + scheduleFrame(); + } +} + +} // namespace fcitx::m3ui diff --git a/src/xcbinputwindow.h b/src/xcbinputwindow.h new file mode 100644 index 0000000..64ec7e7 --- /dev/null +++ b/src/xcbinputwindow.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_XCBINPUTWINDOW_H_ +#define _FCITX_UI_CLASSIC_XCBINPUTWINDOW_H_ + +#include +#include +#include "fcitx-utils/rect.h" +#include "fcitx/inputcontext.h" +#include "inputwindow.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +class XCBInputWindow : public XCBWindow, protected InputWindow { +public: + XCBInputWindow(XCBUI *ui); + + void postCreateWindow() override; + void update(InputContext *inputContext); + void updatePosition(InputContext *inputContext); + + bool filterEvent(xcb_generic_event_t *event) override; + + void updateDPI(InputContext *inputContext); + +private: + void repaint(); + const Rect *getClosestScreen(const Rect &cursorRect) const; + int calculatePositionX(const Rect &cursorRect, + const Rect *closestScreen) const; + int calculatePositionY(const Rect &cursorRect, + const Rect *closestScreen) const; + + xcb_atom_t atomBlur_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_XCBINPUTWINDOW_H_ diff --git a/src/xcbmenu.cpp b/src/xcbmenu.cpp new file mode 100644 index 0000000..9647bfe --- /dev/null +++ b/src/xcbmenu.cpp @@ -0,0 +1,862 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "xcbmenu.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/color.h" +#include "fcitx-utils/connectableobject.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/log.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/signals.h" +#include "fcitx-utils/trackableobject.h" +#include "fcitx/action.h" +#include "fcitx/inputcontext.h" +#include "fcitx/menu.h" +#include "fcitx/userinterfacemanager.h" +#include "common.h" +#include "theme.h" +#include "xcb_public.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +XCBMenu::XCBMenu(XCBUI *ui, MenuPool *pool, Menu *menu) + : XCBWindow(ui), pool_(pool), menu_(menu), + atomBlur_(ui_->parent()->xcb()->call( + ui_->displayName(), "_KDE_NET_WM_BLUR_BEHIND_REGION", false)) { + fontMap_.reset(pango_cairo_font_map_new()); + context_.reset(pango_font_map_create_context(fontMap_.get())); + rootNode_.reset(YGNodeNew()); + if (auto *ic = ui_->parent()->instance()->mostRecentInputContext()) { + lastRelevantIc_ = ic->watch(); + } + createWindow(ui_->visualId()); +} + +XCBMenu::~XCBMenu() {} + +bool XCBMenu::filterEvent(xcb_generic_event_t *event) { + uint8_t response_type = event->response_type & ~0x80; + switch (response_type) { + case XCB_EXPOSE: { + auto *expose = reinterpret_cast(event); + if (expose->window == wid_) { + CLASSICUI_DEBUG() << "Menu received expose event"; + update(); + return true; + } + break; + } + case XCB_FOCUS_IN: { + auto *focusIn = reinterpret_cast(event); + if (focusIn->event == wid_) { + if (focusIn->detail == XCB_NOTIFY_DETAIL_POINTER) { + return true; + } + // FCITX_INFO() << this << " Focus in"; + return true; + } + break; + } + case XCB_FOCUS_OUT: { + auto *focusOut = reinterpret_cast(event); + if (focusOut->event == wid_) { + if (focusOut->detail == XCB_NOTIFY_DETAIL_POINTER) { + return true; + } + // FCITX_INFO() << this << " Focus out " << subMenuIndex_; + if (subMenuIndex_ < 0) { + hideChilds(); + hide(); + hideParents(); + } + return true; + } + break; + } + case XCB_BUTTON_PRESS: { + auto *buttonPress = reinterpret_cast(event); + if (buttonPress->event != wid_) { + break; + } + if (buttonPress->detail != XCB_BUTTON_INDEX_1) { + hideAll(); + return true; + } + if (auto *menu = + childByPosition(buttonPress->root_x, buttonPress->root_y)) { + menu->handleButtonPress( + menu->logicalFromPhysical(buttonPress->root_x - menu->x_), + menu->logicalFromPhysical(buttonPress->root_y - menu->y_)); + } else { + hideAll(); + return true; + } + return true; + } + case XCB_MOTION_NOTIFY: { + auto *motion = reinterpret_cast(event); + if (motion->event != wid_) { + break; + } + + if (auto *menu = childByPosition(motion->root_x, motion->root_y)) { + menu->handleMotionNotify( + menu->logicalFromPhysical(motion->root_x - menu->x_), + menu->logicalFromPhysical(motion->root_y - menu->y_)); + } + return true; + } + case XCB_ENTER_NOTIFY: { + auto *enter = reinterpret_cast(event); + if (enter->event != wid_) { + break; + } + + if (auto *menu = childByPosition(enter->root_x, enter->root_y)) { + menu->hasMouse_ = true; + return true; + } + break; + } + case XCB_LEAVE_NOTIFY: { + auto *leave = reinterpret_cast(event); + if (leave->event != wid_) { + break; + } + if (auto *menu = childByPosition(leave->root_x, leave->root_y)) { + menu->hasMouse_ = false; + menu->setHoveredIndex(-1); + return true; + } + break; + } + case XCB_KEY_PRESS: { + auto *key = reinterpret_cast(event); + if (key->event == wid_) { + return true; + } + break; + } + default: + break; + } + return false; +} + +void XCBMenu::handleButtonPress(int eventX, int eventY) { + for (size_t i = 0; i < items_.size(); i++) { + if (items_[i].isSeparator_ || + !items_[i].region_.contains(eventX, eventY)) { + continue; + } + if (items_[i].hasSubMenu_) { + return; + } + // Check if actions is still good. + auto actions = menu_->actions(); + if (i >= actions.size()) { + break; + } + auto *ic = lastRelevantIc(); + if (!ic) { + ic = ui_->parent() + ->instance() + ->inputContextManager() + .dummyInputContext(); + } + + auto id = actions[i]->id(); + // Why we need to delay the event, because we + // want to make ic has focus. + activateTimer_ = ui_->parent()->instance()->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 30000, 0, + [this, that = this->watch(), icRef = ic->watch(), + id](EventSourceTime *, uint64_t) { + if (!that.isValid()) { + return true; + } + // FCITX_INFO() << "Timer Triggered"; + if (auto *ic = icRef.get()) { + auto *action = ui_->parent() + ->instance() + ->userInterfaceManager() + .lookupActionById(id); + if (action) { + action->activate(ic); + } + } + activateTimer_.reset(); + return true; + }); + break; + } + + hideAll(); +} + +void XCBMenu::handleMotionNotify(int eventX, int eventY) { + for (size_t i = 0; i < items_.size(); i++) { + if (!items_[i].isSeparator_ && + items_[i].region_.contains(eventX, eventY)) { + setHoveredIndex(i); + return; + } + } +} + +void XCBMenu::hide() { + if (!visible_) { + return; + } + // FCITX_INFO() << "Hide " << this; + visible_ = false; + setParent(nullptr); + xcb_unmap_window(ui_->connection(), wid_); + if (ui_->pointerGrabber() == this) { + ui_->ungrabPointer(); + } +} + +void XCBMenu::hideParents() { + // FCITX_INFO() << "Hide Parent " << this; + if (auto *parent = parent_.get()) { + parent->hideParents(); + parent->hide(); + } +} + +void XCBMenu::hideChilds() { + // FCITX_INFO() << "Hide Childs " << this; + if (auto *child = child_.get()) { + child->hideChilds(); + child->hide(); + } +} + +void XCBMenu::hideAll() { + hideParents(); + hide(); + hideChilds(); +} + +bool XCBMenu::childHasMouse() const { + auto ref = child_; + while (auto *child = ref.get()) { + if (child->hasMouse_) { + return true; + } + ref = child->child_; + } + return false; +} + +XCBMenu *XCBMenu::childByPosition(int rootX, int rootY) { + if (ui_->pointerGrabber() != this) { + return this; + } + + XCBMenu *result = this; + while (auto *child = result->child_.get()) { + result = child; + } + while (result) { + Rect rect; + rect.setPosition(result->x_, result->y_) + .setSize(result->physicalWidth_, result->physicalHeight_); + if (rect.contains(rootX, rootY)) { + break; + } + result = result->parent_.get(); + } + return result; +} + +void XCBMenu::hideTillMenuHasMouseOrTopLevel() { + // Go to the innermost child. + auto *menu = this; + while (auto *child = menu->child_.get()) { + menu = child; + } + + menu->hideTillMenuHasMouseOrTopLevelHelper(); +} + +void XCBMenu::hideTillMenuHasMouseOrTopLevelHelper() { + if (parent_.isNull() || hasMouse_) { + update(); + return; + } + auto *parent = parent_.get(); + hide(); // Hide will reset parent. + if (parent) { + parent->hideTillMenuHasMouseOrTopLevelHelper(); + } +} + +void XCBMenu::setHoveredIndex(int idx) { + if (hoveredIndex_ == idx) { + return; + } + // FCITX_INFO() << this << " setHoveredIndex(): " << idx + // << " hasMouse: " << hasMouse_ + // << " child is valid: " << child_.isValid(); + + hoveredIndex_ = idx; + update(); + pool_->setPopupMenuTimer( + ui_->parent()->instance()->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 300000, 0, + [this, that = this->watch()](EventSourceTime *, uint64_t) { + if (!that.isValid()) { + return true; + } + do { + // FCITX_INFO() << this << " in timer"; + if (hoveredIndex_ >= 0 && subMenuIndex_ == hoveredIndex_) { + // Mouse is on same menu item. + break; + } + + if (hoveredIndex_ >= 0) { + // FCITX_INFO() << this << " in timer branch 1"; + // The current subMenu anyway is not the hovered one. + hideChilds(); + subMenuIndex_ = -1; + auto item = + actionAt(static_cast(hoveredIndex_)); + if (!item.first || !item.second) { + break; + } + // If we agree on this that current item has subMenu + if (item.first->hasSubMenu_ && item.second->menu()) { + auto *newMenu = pool_->requestMenu( + ui_, item.second->menu(), this); + subMenuIndex_ = hoveredIndex_; + + // FCITX_INFO() << this << " in timer show submenu " + // << newMenu; + newMenu->show( + physicalFromLogical(item.first->region_) + .translated(x_, y_), + ConstrainAdjustment::Slide); + } + } else { + /// FCITX_INFO() << this << " in timer branch 2"; + // If we are not display any sub menu, and we don't have + // mouse in the window. + hideTillMenuHasMouseOrTopLevel(); + } + update(); + } while (0); + pool_->setPopupMenuTimer(nullptr); + return true; + })); +} + +std::pair XCBMenu::actionAt(size_t index) { + if (items_.size() <= index) { + return {}; + } + + auto actions = menu_->actions(); + if (actions.size() <= index || actions.size() != items_.size()) { + return {}; + } + + return {&items_[index], actions[index]}; +} + +void XCBMenu::updateDPI(int x, int y) { + setScale(scaleForDPI(ui_->dpiByPosition(x, y))); +} + +void XCBMenu::updateBlur() { + if (!atomBlur_) { + return; + } + + const auto width = + static_cast(YGNodeLayoutGetWidth(rootNode_.get())); + const auto height = + static_cast(YGNodeLayoutGetHeight(rootNode_.get())); + Rect logicalRect(0, 0, width, height); + auto &theme = ui_->parent()->theme(); + const auto &menu = *theme.menu; + shrink(logicalRect, *menu.blurMargin); + if (!*menu.enableBlur || logicalRect.isEmpty()) { + xcb_delete_property(ui_->connection(), wid_, atomBlur_); + return; + } + + const auto rect = physicalFromLogical(logicalRect); + std::vector data; + if (menu.blurMask->empty()) { + data = {static_cast(rect.left()), + static_cast(rect.top()), + static_cast(rect.width()), + static_cast(rect.height())}; + } else { + const auto region = + theme.mask(theme.menuBlurMaskConfig(), width, height); + for (const auto &maskRect : region) { + const auto physicalRect = physicalFromLogical(maskRect); + data.push_back(physicalRect.left()); + data.push_back(physicalRect.top()); + data.push_back(physicalRect.width()); + data.push_back(physicalRect.height()); + } + } + xcb_change_property(ui_->connection(), XCB_PROP_MODE_REPLACE, wid_, + atomBlur_, XCB_ATOM_CARDINAL, 32, data.size(), + data.data()); +} + +void XCBMenu::renderYogaNode(cairo_t *cr, YGNodeRef node) { + if (!node) { + return; + } + + cairo_save(cr); + cairo_translate(cr, YGNodeLayoutGetLeft(node), YGNodeLayoutGetTop(node)); + + cairoSetSourceColor(cr, node == rootNode_.get() ? Color(0, 0, 255, 128) + : Color(255, 0, 0, 76)); + cairo_rectangle(cr, 0, 0, YGNodeLayoutGetWidth(node), + YGNodeLayoutGetHeight(node)); + cairo_stroke(cr); + + const auto childCount = YGNodeGetChildCount(node); + for (uint32_t i = 0; i < childCount; i++) { + renderYogaNode(cr, YGNodeGetChild(node, i)); + } + + cairo_restore(cr); +} + +void XCBMenu::update() { + auto *ic = lastRelevantIc(); + if (!ic) { + ic = ui_->parent() + ->instance() + ->inputContextManager() + .dummyInputContext(); + } + + auto updateIfLarger = [](size_t &m, size_t n) { m = std::max(n, m); }; + + auto actions = menu_->actions(); + while (items_.size() < actions.size()) { + items_.emplace_back(context_.get()); + } + items_.erase(items_.begin() + actions.size(), items_.end()); + auto &theme = ui_->parent()->theme(); + auto *fontDesc = pango_font_description_from_string( + ui_->parent()->config().menuFont->c_str()); + pango_context_set_font_description(context_.get(), fontDesc); + pango_font_description_free(fontDesc); + ui_->fontOption().setupPangoContext(context_.get()); + + const auto &textMargin = *theme.menu->textMargin; + int i = 0; + const auto &separator = theme.loadBackground(*theme.menu->separator); + const auto &checkBox = theme.loadBackground(*theme.menu->checkBox); + const auto &subMenu = theme.loadBackground(*theme.menu->subMenu); + const auto &highlightMargin = *theme.menu->highlight->margin; + size_t maxItemWidth = 0; + size_t maxItemHeight = 0; + + bool hasCheckable = std::ranges::any_of(actions, [](const Action *action) { + return action->isCheckable() && !action->isSeparator(); + }); + for (auto *action : actions) { + auto &item = items_[i]; + item.isHighlight_ = + hoveredIndex_ >= 0 ? (hoveredIndex_ == i) : (subMenuIndex_ == i); + i++; + item.hasSubMenu_ = action->menu() != nullptr; + item.isSeparator_ = action->isSeparator(); + if (action->isSeparator()) { + continue; + } + + // Calculate size for real items. + auto text = action->shortText(ic); + pango_layout_context_changed(item.layout_.get()); + pango_layout_set_text(item.layout_.get(), text.c_str(), text.size()); + item.textWidth_ = item.textHeight_ = 0; + pango_layout_get_pixel_size(item.layout_.get(), &item.textWidth_, + &item.textHeight_); + + size_t itemWidth = 0; + size_t itemHeight = 0; + if (hasCheckable) { + itemWidth += checkBox.width(); + updateIfLarger(itemHeight, checkBox.height()); + } + item.isChecked_ = action->isChecked(ic); + itemWidth += item.textWidth_; + updateIfLarger(itemHeight, item.textHeight_); + itemWidth += subMenu.width(); + updateIfLarger(itemHeight, subMenu.height()); + + updateIfLarger(maxItemWidth, itemWidth); + updateIfLarger(maxItemHeight, itemHeight); + } + + YGNodeRemoveAllChildren(rootNode_.get()); + YGNodeReset(rootNode_.get()); + YGNodeStyleSetFlexDirection(rootNode_.get(), YGFlexDirectionColumn); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeLeft, + *theme.menu->contentMargin->marginLeft); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeRight, + *theme.menu->contentMargin->marginRight); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeTop, + *theme.menu->contentMargin->marginTop); + YGNodeStyleSetPadding(rootNode_.get(), YGEdgeBottom, + *theme.menu->contentMargin->marginBottom); + YGNodeStyleSetMinWidth(rootNode_.get(), 1); + YGNodeStyleSetMinHeight(rootNode_.get(), 1); + YGNodeStyleSetGap(rootNode_.get(), YGGutterRow, *theme.menu->spacing); + + for (size_t index = 0; index < items_.size(); index++) { + auto &item = items_[index]; + YGNodeRemoveAllChildren(item.self_.get()); + YGNodeRemoveAllChildren(item.leading_.get()); + YGNodeReset(item.self_.get()); + YGNodeReset(item.leading_.get()); + YGNodeReset(item.checkBox_.get()); + YGNodeReset(item.text_.get()); + YGNodeReset(item.subMenu_.get()); + + if (item.isSeparator_) { + YGNodeStyleSetHeight(item.self_.get(), separator.isPattern() + ? 2 + : separator.height()); + YGNodeInsertChild(rootNode_.get(), item.self_.get(), index); + continue; + } + + YGNodeStyleSetFlexDirection(item.self_.get(), YGFlexDirectionRow); + YGNodeStyleSetJustifyContent(item.self_.get(), YGJustifySpaceBetween); + YGNodeStyleSetAlignItems(item.self_.get(), YGAlignCenter); + YGNodeStyleSetWidth(item.self_.get(), maxItemWidth); + YGNodeStyleSetHeight(item.self_.get(), maxItemHeight); + YGNodeStyleSetMargin(item.self_.get(), YGEdgeLeft, + *textMargin.marginLeft); + YGNodeStyleSetMargin(item.self_.get(), YGEdgeRight, + *textMargin.marginRight); + YGNodeStyleSetMargin(item.self_.get(), YGEdgeTop, + *textMargin.marginTop); + YGNodeStyleSetMargin(item.self_.get(), YGEdgeBottom, + *textMargin.marginBottom); + + YGNodeStyleSetFlexDirection(item.leading_.get(), YGFlexDirectionRow); + YGNodeStyleSetAlignItems(item.leading_.get(), YGAlignCenter); + if (hasCheckable) { + YGNodeStyleSetWidth(item.checkBox_.get(), checkBox.width()); + YGNodeStyleSetHeight(item.checkBox_.get(), checkBox.height()); + YGNodeInsertChild(item.leading_.get(), item.checkBox_.get(), 0); + } + YGNodeStyleSetWidth(item.text_.get(), item.textWidth_); + YGNodeStyleSetHeight(item.text_.get(), item.textHeight_); + YGNodeStyleSetWidth(item.subMenu_.get(), subMenu.width()); + YGNodeStyleSetHeight(item.subMenu_.get(), subMenu.height()); + YGNodeInsertChild(item.leading_.get(), item.text_.get(), hasCheckable); + YGNodeInsertChild(item.self_.get(), item.leading_.get(), 0); + YGNodeInsertChild(item.self_.get(), item.subMenu_.get(), 1); + YGNodeInsertChild(rootNode_.get(), item.self_.get(), index); + } + + YGNodeCalculateLayout(rootNode_.get(), YGUndefined, YGUndefined, + YGDirectionLTR); + const auto width = + static_cast(YGNodeLayoutGetWidth(rootNode_.get())); + const auto height = + static_cast(YGNodeLayoutGetHeight(rootNode_.get())); + + const auto oldPhysicalWidth = physicalWidth_; + const auto oldPhysicalHeight = physicalHeight_; + resize(width, height); + if (physicalWidth_ != oldPhysicalWidth || + physicalHeight_ != oldPhysicalHeight) { + updateBlur(); + } + + cairo_t *c = cairo_create(prerender()); + auto surface = cairo_get_target(c); + auto surfaceWidth = cairo_image_surface_get_width(surface); + auto surfaceHeight = cairo_image_surface_get_height(surface); + auto renderWidth = surfaceWidth / scale_; + auto renderHeight = surfaceHeight / scale_; + cairo_set_operator(c, CAIRO_OPERATOR_CLEAR); + cairo_paint(c); + cairo_set_operator(c, CAIRO_OPERATOR_OVER); + theme.paint(c, *theme.menu->background, 0, 0, renderWidth, renderHeight, + /*alpha=*/1.0); + for (auto &item : items_) { + if (item.isSeparator_) { + const ThemeImage &separator = + theme.loadBackground(*theme.menu->separator); + theme.paint(c, *theme.menu->separator, + absolute(item.self_.get()), + absolute(item.self_.get()), + renderWidth - *theme.menu->contentMargin->marginLeft - + *theme.menu->contentMargin->marginRight, + (separator.isPattern() ? 2 : -1), /*alpha=*/1.0); + continue; + } + + const auto itemLeft = absolute(item.self_.get()); + const auto itemTop = absolute(item.self_.get()); + const auto itemWidth = YGNodeLayoutGetWidth(item.self_.get()); + const auto itemHeight = YGNodeLayoutGetHeight(item.self_.get()); + item.region_ + .setPosition(itemLeft - *highlightMargin.marginLeft, + itemTop - *highlightMargin.marginTop) + .setSize(itemWidth + *highlightMargin.marginLeft + + *highlightMargin.marginRight, + itemHeight + *highlightMargin.marginTop + + *highlightMargin.marginBottom); + if (item.isHighlight_) { + theme.paint(c, *theme.menu->highlight, item.region_.left(), + item.region_.top(), item.region_.width(), + item.region_.height(), + /*alpha=*/1.0); + } + + if (item.isChecked_) { + theme.paint(c, *theme.menu->checkBox, + absolute(item.checkBox_.get()), + absolute(item.checkBox_.get()), -1, + -1, + /*alpha=*/1.0); + } + + if (item.hasSubMenu_) { + theme.paint(c, *theme.menu->subMenu, + absolute(item.subMenu_.get()), + absolute(item.subMenu_.get()), -1, + -1, + /*alpha=*/1.0); + } + + cairo_save(c); + if (item.isHighlight_) { + cairoSetSourceColor(c, theme.menuSelectedItemText()); + } else { + cairoSetSourceColor(c, theme.menuText()); + } + cairo_translate(c, absolute(item.text_.get()), + absolute(item.text_.get())); + pango_cairo_show_layout(c, item.layout_.get()); + cairo_restore(c); + } + + if (m3ui_logcategory().checkLogLevel(Debug)) { + renderYogaNode(c, rootNode_.get()); + } + + cairo_destroy(c); + render(); +} + +void XCBMenu::postCreateWindow() { + if (ui_->ewmh()->_NET_WM_WINDOW_TYPE_MENU && + ui_->ewmh()->_NET_WM_WINDOW_TYPE_POPUP_MENU && + ui_->ewmh()->_NET_WM_WINDOW_TYPE) { + uint32_t types[] = {ui_->ewmh()->_NET_WM_WINDOW_TYPE_MENU, + ui_->ewmh()->_NET_WM_WINDOW_TYPE_POPUP_MENU}; + xcb_ewmh_set_wm_window_type(ui_->ewmh(), wid_, 2, types); + } + + if (ui_->ewmh()->_NET_WM_PID) { + xcb_ewmh_set_wm_pid(ui_->ewmh(), wid_, getpid()); + } + + const char name[] = "Fcitx5 Menu Window"; + xcb_icccm_set_wm_name(ui_->connection(), wid_, XCB_ATOM_STRING, 8, + sizeof(name) - 1, name); + const char klass[] = "fcitx\0fcitx"; + xcb_icccm_set_wm_class(ui_->connection(), wid_, sizeof(klass) - 1, klass); + addEventMaskToWindow( + ui_->connection(), wid_, + XCB_EVENT_MASK_EXPOSURE | XCB_EVENT_MASK_BUTTON_PRESS | + XCB_EVENT_MASK_BUTTON_RELEASE | XCB_EVENT_MASK_FOCUS_CHANGE | + XCB_EVENT_MASK_ENTER_WINDOW | XCB_EVENT_MASK_LEAVE_WINDOW | + XCB_EVENT_MASK_VISIBILITY_CHANGE | XCB_EVENT_MASK_POINTER_MOTION); +} + +void XCBMenu::setParent(XCBMenu *parent) { + if (auto *oldParent = parent_.get()) { + if (parent == oldParent) { + return; + } + + parent_.unwatch(); + oldParent->setChild(nullptr); + } + + if (parent) { + parent_ = parent->watch(); + parent->setChild(this); + } else { + parent_.unwatch(); + } +} + +void XCBMenu::setChild(XCBMenu *child) { + if (child) { + child_ = child->watch(); + } else { + child_.unwatch(); + subMenuIndex_ = -1; + update(); + } +} + +void XCBMenu::setInputContext(TrackableObjectReference ic) { + lastRelevantIc_ = std::move(ic); +} + +InputContext *XCBMenu::lastRelevantIc() { + if (auto *ic = lastRelevantIc_.get()) { + return ic; + } + return ui_->parent()->instance()->mostRecentInputContext(); +} + +void XCBMenu::show(Rect rect, ConstrainAdjustment adjustY) { + // FCITX_INFO() << this << " show() " << hoveredIndex_; + if (visible_) { + return; + } + visible_ = true; + hoveredIndex_ = -1; + subMenuIndex_ = -1; + int x = rect.left(); + int y = rect.top(); + updateDPI(x, y); + update(); + updateBlur(); + const Rect *closestScreen = nullptr; + int shortestDistance = INT_MAX; + for (const auto &rect : ui_->screenRects()) { + int thisDistance = rect.first.distance(x, y); + if (thisDistance < shortestDistance) { + shortestDistance = thisDistance; + closestScreen = &rect.first; + } + } + + x = x + rect.width(); + + if (closestScreen) { + if (x + physicalWidth_ > closestScreen->right()) { + x = rect.left() - physicalWidth_; + } + + switch (adjustY) { + case ConstrainAdjustment::Slide: + if (y + physicalHeight_ > closestScreen->bottom()) { + y = closestScreen->bottom() - physicalHeight_; + } + break; + case ConstrainAdjustment::Flip: + if (y + physicalHeight_ > closestScreen->bottom()) { + y = rect.top() - physicalHeight_; + } + break; + }; + + y = std::max(y, closestScreen->top()); + } + + xcb_params_configure_window_t wc; + wc.x = x; + wc.y = y; + wc.stack_mode = XCB_STACK_MODE_ABOVE; + xcb_aux_configure_window(ui_->connection(), wid_, + XCB_CONFIG_WINDOW_STACK_MODE | + XCB_CONFIG_WINDOW_X | XCB_CONFIG_WINDOW_Y, + &wc); + + xcb_map_window(ui_->connection(), wid_); + if (parent_.isNull()) { + ui_->grabPointer(this); + } + x_ = x; + y_ = y; +} + +void XCBMenu::raise() { + xcb_params_configure_window_t wc; + wc.stack_mode = XCB_STACK_MODE_ABOVE; + xcb_aux_configure_window(ui_->connection(), wid_, + XCB_CONFIG_WINDOW_STACK_MODE, &wc); +} + +XCBMenu *MenuPool::requestMenu(XCBUI *ui, Menu *menu, XCBMenu *parent) { + auto *xcbMenu = findOrCreateMenu(ui, menu); + xcbMenu->setParent(parent); + if (parent) { + xcbMenu->setInputContext(parent->inputContext()); + } else { + if (auto *ic = ui->parent()->instance()->mostRecentInputContext()) { + xcbMenu->setInputContext(ic->watch()); + } else { + xcbMenu->setInputContext({}); + } + } + return xcbMenu; +} + +XCBMenu *MenuPool::findOrCreateMenu(XCBUI *ui, Menu *menu) { + auto iter = pool_.find(menu); + if (iter != pool_.end()) { + return &iter->second.first; + } + + ScopedConnection conn = menu->connect([this](void *data) { + Menu *menu = static_cast(data); + pool_.erase(menu); + }); + + auto result = pool_.emplace( + std::piecewise_construct, std::forward_as_tuple(menu), + std::forward_as_tuple(std::piecewise_construct, + std::forward_as_tuple(ui, this, menu), + std::forward_as_tuple(std::move(conn)))); + return &result.first->second.first; +} + +} // namespace fcitx::m3ui diff --git a/src/xcbmenu.h b/src/xcbmenu.h new file mode 100644 index 0000000..36922ca --- /dev/null +++ b/src/xcbmenu.h @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_XCBMENU_H_ +#define _FCITX_UI_CLASSIC_XCBMENU_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx-utils/signals.h" +#include "fcitx-utils/trackableobject.h" +#include "fcitx/action.h" +#include "fcitx/inputcontext.h" +#include "fcitx/menu.h" +#include "common.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +class MenuPool; + +struct MenuItem { + MenuItem(PangoContext *context) : layout_(pango_layout_new(context)) { + self_.reset(YGNodeNew()); + leading_.reset(YGNodeNew()); + checkBox_.reset(YGNodeNew()); + text_.reset(YGNodeNew()); + subMenu_.reset(YGNodeNew()); + } + + bool hasSubMenu_ = false; + bool isHighlight_ = false; + bool isSeparator_ = false; + bool isChecked_ = false; + GObjectUniquePtr layout_; + Rect region_; + int textWidth_ = 0, textHeight_ = 0; + UniqueCPtr self_; + UniqueCPtr leading_; + UniqueCPtr checkBox_; + UniqueCPtr text_; + UniqueCPtr subMenu_; +}; + +enum class ConstrainAdjustment { Slide, Flip }; + +class XCBMenu : public XCBWindow, public TrackableObject { +public: + XCBMenu(XCBUI *ui, MenuPool *pool, Menu *menu); + ~XCBMenu(); + void show(Rect rect, ConstrainAdjustment adjustY); + + // Hide menu itself. + void hide(); + + // Hide all of its parent. + void hideParents(); + + // Hide all menu on the chain until the one has mouse. + void hideTillMenuHasMouseOrTopLevel(); + + // Hide all of its child. + void hideChilds(); + + void hideAll(); + + // Raise the menu. + void raise(); + + bool filterEvent(xcb_generic_event_t *event) override; + void postCreateWindow() override; + + void setParent(XCBMenu *parent); + void setInputContext(TrackableObjectReference ic); + TrackableObjectReference inputContext() const { + return lastRelevantIc_; + } + + bool childHasMouse() const; + +private: + void handleButtonPress(int eventX, int eventY); + void handleMotionNotify(int eventX, int eventY); + XCBMenu *childByPosition(int rootX, int rootY); + + void hideTillMenuHasMouseOrTopLevelHelper(); + InputContext *lastRelevantIc(); + void update(); + void setHoveredIndex(int idx); + void setChild(XCBMenu *child); + void updateDPI(int x, int y); + void updateBlur(); + std::pair actionAt(size_t index); + void renderYogaNode(cairo_t *cr, YGNodeRef node); + + MenuPool *pool_; + + GObjectUniquePtr fontMap_; + GObjectUniquePtr context_; + std::vector items_; + UniqueCPtr rootNode_; + + ScopedConnection destroyed_; + TrackableObjectReference lastRelevantIc_; + Menu *menu_; + TrackableObjectReference parent_; + TrackableObjectReference child_; + int x_ = 0; + int y_ = 0; + bool hasMouse_ = false; + bool visible_ = false; + int subMenuIndex_ = -1; + int hoveredIndex_ = -1; + xcb_atom_t atomBlur_; + std::unique_ptr activateTimer_; +}; + +class MenuPool { +public: + XCBMenu *requestMenu(XCBUI *ui, Menu *menu, XCBMenu *parent); + + void setPopupMenuTimer(std::unique_ptr popupMenuTimer) { + popupMenuTimer_ = std::move(popupMenuTimer); + } + +private: + XCBMenu *findOrCreateMenu(XCBUI *ui, Menu *menu); + + std::unordered_map> pool_; + std::unique_ptr popupMenuTimer_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_XCBMENU_H_ diff --git a/src/xcbtraywindow.cpp b/src/xcbtraywindow.cpp new file mode 100644 index 0000000..a00d47a --- /dev/null +++ b/src/xcbtraywindow.cpp @@ -0,0 +1,543 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#include "xcbtraywindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/i18n.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx/action.h" +#include "fcitx/inputcontext.h" +#include "fcitx/inputmethodentry.h" +#include "fcitx/inputmethodmanager.h" +#include "fcitx/statusarea.h" +#include "fcitx/userinterfacemanager.h" +#include "common.h" +#include "xcb_public.h" +#include "xcbmenu.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +#define SYSTEM_TRAY_REQUEST_DOCK 0 +#define SYSTEM_TRAY_BEGIN_MESSAGE 1 +#define SYSTEM_TRAY_CANCEL_MESSAGE 2 + +#define ATOM_SELECTION 0 +#define ATOM_MANAGER 1 +#define ATOM_SYSTEM_TRAY_OPCODE 2 +#define ATOM_ORIENTATION 3 +#define ATOM_VISUAL 4 + +XCBTrayWindow::XCBTrayWindow(XCBUI *ui) : XCBWindow(ui, 48, 48) { + for (auto &separator : separatorActions_) { + separator.setSeparator(true); + } + groupAction_.setShortText(_("Group")); + groupAction_.setMenu(&groupMenu_); + configureAction_.setShortText(_("Input Method Settings")); + restartAction_.setShortText(_("Restart")); + exitAction_.setShortText(_("Exit")); + menu_.addAction(&groupAction_); + menu_.addAction(&separatorActions_[0]); + menu_.addAction(&configureAction_); + menu_.addAction(&restartAction_); + menu_.addAction(&exitAction_); + + configureAction_.connect( + [this](InputContext *) { ui_->parent()->instance()->configure(); }); + restartAction_.connect( + [this](InputContext *) { ui_->parent()->instance()->restart(); }); + exitAction_.connect( + [this](InputContext *) { ui_->parent()->instance()->exit(); }); + + auto &uiManager = ui_->parent()->instance()->userInterfaceManager(); + uiManager.registerAction(&groupAction_); + uiManager.registerAction(&configureAction_); + uiManager.registerAction(&restartAction_); + uiManager.registerAction(&exitAction_); + +#if 0 + inputMethodMenu_.addAction(&testAction1_); + inputMethodMenu_.addAction(&testAction2_); + testAction1_.setMenu(&testMenu1_); + testAction2_.setMenu(&testMenu2_); + testMenu1_.addAction(&testSubAction1_); + testMenu2_.addAction(&testSubAction2_); + testAction1_.setShortText("TEST1"); + testAction2_.setShortText("TEST2"); + testSubAction1_.setShortText("TESTSUB1"); + testSubAction2_.setShortText("TESTSUB2"); + uiManager.registerAction(&testAction1_); + uiManager.registerAction(&testAction2_); + uiManager.registerAction(&testSubAction1_); + uiManager.registerAction(&testSubAction2_); +#endif +} + +bool XCBTrayWindow::filterEvent(xcb_generic_event_t *event) { + uint8_t response_type = event->response_type & ~0x80; + switch (response_type) { + case XCB_CLIENT_MESSAGE: { + auto *client_message = + reinterpret_cast(event); + if (client_message->type == atoms_[ATOM_MANAGER] && + client_message->format == 32 && + client_message->data.data32[1] == atoms_[ATOM_SELECTION] && + dockWindow_ == XCB_WINDOW_NONE) { + refreshDockWindow(); + return true; + } + break; + } + + case XCB_BUTTON_PRESS: { + auto *press = reinterpret_cast(event); + if (press->event == wid_) { + if (press->detail == XCB_BUTTON_INDEX_3) { + updateMenu(); + XCBMenu *menu = menuPool_.requestMenu(ui_, &menu_, nullptr); + menu->show(Rect() + .setPosition(press->root_x, press->root_y) + .setSize(1, 1), + ConstrainAdjustment::Flip); + } else if (press->detail == XCB_BUTTON_INDEX_1) { + ui_->parent()->instance()->toggle(); + } else if (press->detail == XCB_BUTTON_INDEX_4) { + ui_->parent()->instance()->enumerate(true); + } else if (press->detail == XCB_BUTTON_INDEX_5) { + ui_->parent()->instance()->enumerate(false); + } + return true; + } + break; + } + + case XCB_EXPOSE: { + auto *expose = reinterpret_cast(event); + if (expose->window == wid_) { + CLASSICUI_DEBUG() << "Tray received expose event"; + update(); + return true; + } + break; + } + case XCB_CONFIGURE_NOTIFY: { + auto *configure = + reinterpret_cast(event); + if (wid_ == configure->event) { + CLASSICUI_DEBUG() << "Tray received configure event"; + hintHeight_ = configure->height; + hintWidth_ = configure->width; + resizeTrayWindow(); + return true; + } + break; + } + case XCB_DESTROY_NOTIFY: { + auto *destroywindow = + reinterpret_cast(event); + if (destroywindow->event == dockWindow_) { + refreshDockWindow(); + return true; + } + break; + } + case XCB_PROPERTY_NOTIFY: { + auto *property = reinterpret_cast(event); + if (property->atom == atoms_[ATOM_VISUAL] && + property->window == dockWindow_) { + createTrayWindow(); + findDock(); + return true; + } + if (property->atom == atoms_[ATOM_ORIENTATION] && + property->window == dockWindow_) { + isHorizontal_ = trayOrientation(); + resizeTrayWindow(); + return true; + } + break; + } + } + return false; +} + +void XCBTrayWindow::initTray() { + char trayAtomNameBuf[100]; + const char *atom_names[] = { + trayAtomNameBuf, "MANAGER", "_NET_SYSTEM_TRAY_OPCODE", + "_NET_SYSTEM_TRAY_ORIENTATION", "_NET_SYSTEM_TRAY_VISUAL"}; + + sprintf(trayAtomNameBuf, "_NET_SYSTEM_TRAY_S%d", ui_->defaultScreen()); + size_t i = 0; + for (const auto *name : atom_names) { + atoms_[i] = ui_->parent()->xcb()->call( + ui_->displayName(), name, false); + i++; + } +} + +void XCBTrayWindow::refreshDockWindow() { + auto cookie = xcb_get_selection_owner(ui_->connection(), atoms_[0]); + auto reply = makeUniqueCPtr( + xcb_get_selection_owner_reply(ui_->connection(), cookie, nullptr)); + if (reply) { + dockWindow_ = reply->owner; + } + + if (dockWindow_) { + CLASSICUI_DEBUG() << "Found dock window"; + addEventMaskToWindow(ui_->connection(), dockWindow_, + XCB_EVENT_MASK_STRUCTURE_NOTIFY); + createTrayWindow(); + findDock(); + } else { + destroyWindow(); + } +} + +void XCBTrayWindow::findDock() { + if (!wid_) { + return; + } + + if (dockWindow_) { + CLASSICUI_DEBUG() << "Send op code to tray"; + sendTrayOpcode(SYSTEM_TRAY_REQUEST_DOCK, wid_, 0, 0); + } +} + +void XCBTrayWindow::createTrayWindow() { + trayVid_ = trayVisual(); + isHorizontal_ = trayOrientation(); + if (trayVid_) { + xcb_screen_t *screen = + xcb_aux_get_screen(ui_->connection(), ui_->defaultScreen()); + trayDepth_ = xcb_aux_get_depth_of_visual(screen, trayVid_); + } else { + trayDepth_ = 0; + } + + createWindow(trayVid_); +} + +void XCBTrayWindow::resizeTrayWindow() { + auto size = isHorizontal_ ? hintHeight_ : hintWidth_; + if (width() != size && height() != size) { + resize(size, size); + xcb_size_hints_t size_hints; + memset(&size_hints, 0, sizeof(size_hints)); + size_hints.flags = XCB_ICCCM_SIZE_HINT_BASE_SIZE; + size_hints.base_width = size; + size_hints.base_height = size; + xcb_icccm_set_wm_normal_hints(ui_->connection(), wid_, &size_hints); + } +} + +void XCBTrayWindow::sendTrayOpcode(long message, long data1, long data2, + long data3) { + xcb_client_message_event_t ev; + + memset(&ev, 0, sizeof(ev)); + ev.response_type = XCB_CLIENT_MESSAGE; + ev.window = dockWindow_; + ev.type = atoms_[ATOM_SYSTEM_TRAY_OPCODE]; + ev.format = 32; + ev.data.data32[0] = XCB_CURRENT_TIME; + ev.data.data32[1] = message; + ev.data.data32[2] = data1; + ev.data.data32[3] = data2; + ev.data.data32[4] = data3; + + xcb_send_event(ui_->connection(), false, dockWindow_, + XCB_EVENT_MASK_NO_EVENT, reinterpret_cast(&ev)); +} + +xcb_visualid_t XCBTrayWindow::trayVisual() { + xcb_visualid_t vid = 0; + if (dockWindow_ == XCB_WINDOW_NONE) { + return 0; + } + auto cookie = + xcb_get_property(ui_->connection(), false, dockWindow_, + atoms_[ATOM_VISUAL], XCB_ATOM_VISUALID, 0, 1); + auto reply = makeUniqueCPtr( + xcb_get_property_reply(ui_->connection(), cookie, nullptr)); + if (!reply || reply->type != XCB_ATOM_VISUALID || reply->format != 32 || + reply->bytes_after != 0) { + return 0; + } + auto *data = static_cast(xcb_get_property_value(reply.get())); + int length = xcb_get_property_value_length(reply.get()); + if (length != 32 / 8) { + return 0; + } + vid = *reinterpret_cast(data); + return vid; +} + +bool XCBTrayWindow::trayOrientation() { + if (dockWindow_ == XCB_WINDOW_NONE) { + return true; + } + auto cookie = + xcb_get_property(ui_->connection(), false, dockWindow_, + atoms_[ATOM_ORIENTATION], XCB_ATOM_CARDINAL, 0, 1); + auto reply = makeUniqueCPtr( + xcb_get_property_reply(ui_->connection(), cookie, nullptr)); + if (!reply || reply->type != XCB_ATOM_CARDINAL || reply->format != 32 || + reply->bytes_after != 0) { + return true; + } + auto *data = static_cast(xcb_get_property_value(reply.get())); + int length = xcb_get_property_value_length(reply.get()); + if (length != 32 / 8) { + return true; + } + constexpr uint32_t SYSTEM_TRAY_ORIENTATION_HORZ = 0; + return (*reinterpret_cast(data)) == + SYSTEM_TRAY_ORIENTATION_HORZ; +} + +void XCBTrayWindow::postCreateWindow() { + if (ui_->ewmh()->_NET_WM_WINDOW_TYPE_DOCK && + ui_->ewmh()->_NET_WM_WINDOW_TYPE) { + xcb_ewmh_set_wm_window_type(ui_->ewmh(), wid_, 1, + &ui_->ewmh()->_NET_WM_WINDOW_TYPE_DOCK); + } + + if (ui_->ewmh()->_NET_WM_PID) { + xcb_ewmh_set_wm_pid(ui_->ewmh(), wid_, getpid()); + } + const char name[] = "Fcitx5 Tray Window"; + xcb_icccm_set_wm_name(ui_->connection(), wid_, XCB_ATOM_STRING, 8, + sizeof(name) - 1, name); + const char klass[] = "fcitx\0fcitx"; + xcb_icccm_set_wm_class(ui_->connection(), wid_, sizeof(klass) - 1, klass); + + addEventMaskToWindow( + ui_->connection(), wid_, + XCB_EVENT_MASK_EXPOSURE | XCB_EVENT_MASK_BUTTON_PRESS | + XCB_EVENT_MASK_BUTTON_RELEASE | XCB_EVENT_MASK_STRUCTURE_NOTIFY | + XCB_EVENT_MASK_ENTER_WINDOW | XCB_EVENT_MASK_LEAVE_WINDOW | + XCB_EVENT_MASK_VISIBILITY_CHANGE | XCB_EVENT_MASK_POINTER_MOTION); + + if (trayDepth_ != 32) { + // Change window attr. + xcb_change_window_attributes_value_list_t list; + list.background_pixmap = XCB_BACK_PIXMAP_PARENT_RELATIVE; + xcb_screen_t *screen = + xcb_aux_get_screen(ui_->connection(), ui_->defaultScreen()); + list.background_pixel = screen->white_pixel; + list.border_pixel = screen->black_pixel; + xcb_change_window_attributes_aux( + ui_->connection(), wid_, + XCB_CW_BACKING_PIXEL | XCB_CW_BORDER_PIXEL | XCB_CW_BACK_PIXMAP, + &list); + } +} + +void XCBTrayWindow::paint(cairo_t *c) { + auto &theme = ui_->parent()->theme(); + auto *instance = ui_->parent()->instance(); + auto *ic = ui_->parent()->instance()->lastFocusedInputContext(); + std::string icon = "input-keyboard"; + std::string label; + const InputMethodEntry *entry = nullptr; + if (ic) { + icon = instance->inputMethodIcon(ic); + entry = instance->inputMethodEntry(ic); + } + if (entry) { + label = entry->label(); + } + + const auto &image = theme.loadImage( + icon, label, std::min(height(), width()), ui_->parent()); + + cairo_save(c); + cairo_set_operator(c, CAIRO_OPERATOR_CLEAR); + cairo_paint(c); + cairo_set_operator(c, CAIRO_OPERATOR_OVER); + double scaleW = 1.0; + double scaleH = 1.0; + if (image.width() != width() || image.height() != height()) { + scaleW = static_cast(width()) / image.width(); + scaleH = static_cast(height()) / image.height(); + if (scaleW > scaleH) { + scaleH = scaleW; + } else { + scaleW = scaleH; + } + } + int aw = scaleW * image.width(); + int ah = scaleH * image.height(); + + image.paintRegion(c, ThemeImage::TilePosition::Center, (width() - aw) / 2.0, + (height() - ah) / 2.0, image.width() * scaleW, + image.height() * scaleH); + cairo_restore(c); +} + +void XCBTrayWindow::update() { + if (!wid_) { + return; + } + + if (auto *surface = prerender()) { + cairo_t *c = cairo_create(surface); + paint(c); + cairo_destroy(c); + render(); + } +} + +void XCBTrayWindow::render() { + if (trayDepth_ != 32) { + xcb_clear_area(ui_->connection(), false, wid_, 0, 0, width(), height()); + } + auto *cr = cairo_create(surface_.get()); + if (trayDepth_ == 32) { + cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); + cairo_paint(cr); + } + cairo_set_operator(cr, CAIRO_OPERATOR_OVER); + cairo_set_source_surface(cr, contentSurface_.get(), 0, 0); + cairo_paint(cr); + cairo_destroy(cr); + cairo_surface_flush(surface_.get()); + CLASSICUI_DEBUG() << "Render"; +} + +void XCBTrayWindow::resume() { + if (dockCallback_) { + return; + } + char trayAtomNameBuf[100]; + sprintf(trayAtomNameBuf, "_NET_SYSTEM_TRAY_S%d", ui_->defaultScreen()); + xcb_screen_t *screen = + xcb_aux_get_screen(ui_->connection(), ui_->defaultScreen()); + addEventMaskToWindow(ui_->connection(), screen->root, + XCB_EVENT_MASK_STRUCTURE_NOTIFY); + dockCallback_ = ui_->parent()->xcb()->call( + ui_->displayName(), trayAtomNameBuf, + [this](xcb_atom_t) { refreshDockWindow(); }); + refreshDockWindow(); +} + +void XCBTrayWindow::suspend() { + if (!dockCallback_) { + return; + } + dockCallback_.reset(); + destroyWindow(); +} + +void XCBTrayWindow::updateMenu() { + updateGroupMenu(); + + auto &imManager = ui_->parent()->instance()->inputMethodManager(); + if (imManager.groupCount() > 1) { + menu_.insertAction(&separatorActions_[0], &groupAction_); + } else { + menu_.removeAction(&groupAction_); + } + updateInputMethodMenu(); + bool start = false; + for (auto *action : menu_.actions()) { + if (action == &separatorActions_[0]) { + start = true; + } else if (action == &configureAction_) { + break; + } else if (start) { + menu_.removeAction(action); + } + } + + bool hasAction = false; + if (auto *ic = ui_->parent()->instance()->mostRecentInputContext()) { + auto &statusArea = ic->statusArea(); + for (auto *action : statusArea.allActions()) { + if (!action->id()) { + // Obviously it's not registered with ui manager. + continue; + } + menu_.insertAction(&configureAction_, action); + hasAction = true; + } + } + if (hasAction) { + menu_.insertAction(&configureAction_, &separatorActions_[1]); + } +} + +void XCBTrayWindow::updateGroupMenu() { + auto &imManager = ui_->parent()->instance()->inputMethodManager(); + std::vector list = imManager.groups(); + groupActions_.clear(); + for (size_t i = 0; i < list.size(); i++) { + const std::string &groupName = list[i]; + groupActions_.emplace_back(); + auto &groupAction = groupActions_.back(); + groupAction.setShortText(groupName); + groupAction.connect( + [&imManager, groupName](InputContext *) { + imManager.setCurrentGroup(groupName); + }); + groupAction.setCheckable(true); + groupAction.setChecked(groupName == imManager.currentGroup().name()); + + auto &uiManager = ui_->parent()->instance()->userInterfaceManager(); + uiManager.registerAction(&groupAction); + groupMenu_.addAction(&groupAction); + } +} + +void XCBTrayWindow::updateInputMethodMenu() { + auto &imManager = ui_->parent()->instance()->inputMethodManager(); + const auto &list = imManager.currentGroup().inputMethodList(); + inputMethodActions_.clear(); + auto *ic = ui_->parent()->instance()->mostRecentInputContext(); + for (size_t i = 0; i < list.size(); i++) { + const auto *entry = imManager.entry(list[i].name()); + if (!entry) { + return; + } + inputMethodActions_.emplace_back(); + auto &inputMethodAction = inputMethodActions_.back(); + inputMethodAction.setShortText(entry->name()); + inputMethodAction.connect( + [this, imName = entry->uniqueName()](InputContext *ic) { + ui_->parent()->instance()->setCurrentInputMethod(ic, imName, + false); + }); + inputMethodAction.setCheckable(true); + inputMethodAction.setChecked( + ic ? (ui_->parent()->instance()->inputMethod(ic) == + entry->uniqueName()) + : false); + + auto &uiManager = ui_->parent()->instance()->userInterfaceManager(); + uiManager.registerAction(&inputMethodAction); + menu_.insertAction(&separatorActions_[0], &inputMethodAction); + } +} +} // namespace fcitx::m3ui diff --git a/src/xcbtraywindow.h b/src/xcbtraywindow.h new file mode 100644 index 0000000..2b520c7 --- /dev/null +++ b/src/xcbtraywindow.h @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2017-2017 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_XCBTRAYWINDOW_H_ +#define _FCITX_UI_CLASSIC_XCBTRAYWINDOW_H_ + +#include +#include +#include +#include +#include +#include "fcitx-utils/handlertable.h" +#include "fcitx/action.h" +#include "fcitx/menu.h" +#include "xcb_public.h" +#include "xcbmenu.h" +#include "xcbui.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +class XCBTrayWindow : public XCBWindow { +public: + XCBTrayWindow(XCBUI *ui); + void initTray(); + + bool filterEvent(xcb_generic_event_t *event) override; + void resume(); + void suspend(); + void update(); + void postCreateWindow() override; + + void updateMenu(); + void updateGroupMenu(); + void updateInputMethodMenu(); + + void render() override; + +private: + void findDock(); + void sendTrayOpcode(long message, long data1, long data2, long data3); + void refreshDockWindow(); + xcb_visualid_t trayVisual(); + bool trayOrientation(); + void paint(cairo_t *cr); + void createTrayWindow(); + void resizeTrayWindow(); + + xcb_window_t dockWindow_ = XCB_WINDOW_NONE; + std::unique_ptr> + dockCallback_; + + xcb_atom_t atoms_[5]; + + MenuPool menuPool_; + + Menu menu_; + SimpleAction groupAction_; + SimpleAction separatorActions_[2]; + SimpleAction configureAction_; + SimpleAction restartAction_; + SimpleAction exitAction_; + +#if 0 + SimpleAction testAction1_; + SimpleAction testAction2_; + Menu testMenu1_; + Menu testMenu2_; + SimpleAction testSubAction1_; + SimpleAction testSubAction2_; +#endif + + xcb_visualid_t trayVid_ = 0; + int trayDepth_ = 0; + + bool isHorizontal_ = true; + int hintWidth_ = 0; + int hintHeight_ = 0; + + Menu groupMenu_; + std::list groupActions_; + std::list inputMethodActions_; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_XCBTRAYWINDOW_H_ diff --git a/src/xcbui.cpp b/src/xcbui.cpp new file mode 100644 index 0000000..b6193ca --- /dev/null +++ b/src/xcbui.cpp @@ -0,0 +1,834 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "xcbui.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/endian_p.h" +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/macros.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx/userinterface.h" +#include "classicui.h" +#include "common.h" +#include "xcb_public.h" +#include "xcbinputwindow.h" +#include "xcbtraywindow.h" +#include "xcbwindow.h" + +namespace fcitx::m3ui { + +void addEventMaskToWindow(xcb_connection_t *conn, xcb_window_t wid, + uint32_t mask) { + auto get_attr_cookie = xcb_get_window_attributes(conn, wid); + auto get_attr_reply = makeUniqueCPtr( + xcb_get_window_attributes_reply(conn, get_attr_cookie, nullptr)); + if (get_attr_reply && (get_attr_reply->your_event_mask & mask) != mask) { + const uint32_t newMask = get_attr_reply->your_event_mask | mask; + xcb_change_window_attributes(conn, wid, XCB_CW_EVENT_MASK, &newMask); + } +} + +xcb_visualid_t findVisual(xcb_screen_t *screen) { + auto *visual = xcb_aux_find_visual_by_attrs(screen, -1, 32); + if (!visual) { + return screen->root_visual; + } + return visual->visual_id; +} + +XCBFontOption forcedDpi(xcb_connection_t *conn, xcb_screen_t *screen) { + int offset = 0; + std::vector resources; + bool more = true; + do { + auto cookie = xcb_get_property(conn, false, screen->root, + XCB_ATOM_RESOURCE_MANAGER, + XCB_ATOM_STRING, offset / 4, 8192); + auto reply = + makeUniqueCPtr(xcb_get_property_reply(conn, cookie, nullptr)); + more = false; + if (reply && reply->format == 8 && reply->type == XCB_ATOM_STRING) { + const auto *start = + static_cast(xcb_get_property_value(reply.get())); + const auto *end = + start + xcb_get_property_value_length(reply.get()); + resources.insert(resources.end(), start, end); + offset += xcb_get_property_value_length(reply.get()); + more = reply->bytes_after != 0; + } + } while (more); + + XCBFontOption option; + auto parse = [](const std::vector &resources, const char *item, + auto callback) { + auto iter = resources.begin(); + auto end = resources.end(); + while (iter < end) { + auto next = std::find(iter, end, '\n'); + int cLen = strlen(item); + if (next - iter > cLen && std::equal(iter, iter + cLen, item)) { + std::string value(iter + cLen, next); + callback(value); + } + + iter = std::next(next); + } + }; + parse(resources, "Xft.dpi:\t", [&option](const std::string &value) { + std::from_chars(value.data(), value.data() + value.size(), option.dpi); + }); + parse(resources, "Xft.antialias:\t", [&option](const std::string &value) { + int antialias = 0; + if (std::from_chars(value.data(), value.data() + value.size(), + antialias) + .ec == std::errc()) { + option.antialias = antialias; + } + }); + parse(resources, "Xft.hintstyle:\t", [&option](const std::string &value) { + if (value == "hintfull") { + option.hint = XCBHintStyle::Full; + } else if (value == "hintnone") { + option.hint = XCBHintStyle::NoHint; + } else if (value == "hintmedium") { + option.hint = XCBHintStyle::Medium; + } else if (value == "hintslight") { + option.hint = XCBHintStyle::Slight; + } + // default + }); + parse(resources, "Xft.rgba:\t", [&option](const std::string &value) { + if (value == "none") { + option.rgba = XCBRGBA::NoRGBA; + } else if (value == "rgb") { + option.rgba = XCBRGBA::RGB; + } else if (value == "bgr") { + option.rgba = XCBRGBA::BGR; + } else if (value == "vrgb") { + option.rgba = XCBRGBA::VRGB; + } else if (value == "vbgr") { + option.rgba = XCBRGBA::VBGR; + } + // default + }); + return option; +} + +void XCBFontOption::setupPangoContext(PangoContext *context) const { + cairo_hint_style_t hint = CAIRO_HINT_STYLE_DEFAULT; + cairo_antialias_t aa = CAIRO_ANTIALIAS_DEFAULT; + cairo_subpixel_order_t subpixel = CAIRO_SUBPIXEL_ORDER_DEFAULT; + switch (this->hint) { + case XCBHintStyle::NoHint: + hint = CAIRO_HINT_STYLE_NONE; + break; + case XCBHintStyle::Slight: + hint = CAIRO_HINT_STYLE_SLIGHT; + break; + case XCBHintStyle::Medium: + hint = CAIRO_HINT_STYLE_MEDIUM; + break; + case XCBHintStyle::Full: + hint = CAIRO_HINT_STYLE_FULL; + break; + default: + hint = CAIRO_HINT_STYLE_DEFAULT; + break; + } + switch (rgba) { + case XCBRGBA::NoRGBA: + subpixel = CAIRO_SUBPIXEL_ORDER_DEFAULT; + break; + case XCBRGBA::RGB: + subpixel = CAIRO_SUBPIXEL_ORDER_RGB; + break; + case XCBRGBA::BGR: + subpixel = CAIRO_SUBPIXEL_ORDER_BGR; + break; + case XCBRGBA::VRGB: + subpixel = CAIRO_SUBPIXEL_ORDER_VRGB; + break; + case XCBRGBA::VBGR: + subpixel = CAIRO_SUBPIXEL_ORDER_VBGR; + break; + default: + subpixel = CAIRO_SUBPIXEL_ORDER_DEFAULT; + break; + } + + if (antialias) { + if (subpixel != CAIRO_SUBPIXEL_ORDER_DEFAULT) { + aa = CAIRO_ANTIALIAS_SUBPIXEL; + } else { + aa = CAIRO_ANTIALIAS_GRAY; + } + } else { + aa = CAIRO_ANTIALIAS_NONE; + } + + auto *options = cairo_font_options_create(); + cairo_font_options_set_hint_style(options, hint); + cairo_font_options_set_subpixel_order(options, subpixel); + cairo_font_options_set_antialias(options, aa); + cairo_font_options_set_hint_metrics(options, CAIRO_HINT_METRICS_ON); + pango_cairo_context_set_font_options(context, options); + cairo_font_options_destroy(options); +} + +XCBUI::XCBUI(M3UI *parent, const std::string &name, xcb_connection_t *conn, + int defaultScreen) + : UIInterface("x11:" + name), parent_(parent), displayName_(name), + conn_(conn), defaultScreen_(defaultScreen) { + ewmh_ = parent_->xcb()->call(displayName_); + inputWindow_ = std::make_unique(this); + trayWindow_ = std::make_unique(this); + + compMgrAtomString_ = "_NET_WM_CM_S" + std::to_string(defaultScreen_); + compMgrAtom_ = parent_->xcb()->call( + displayName_, compMgrAtomString_, false); + + auto xsettingsSelectionString = + "_XSETTINGS_S" + std::to_string(defaultScreen_); + managerAtom_ = + parent_->xcb()->call(displayName_, "MANAGER", false); + xsettingsSelectionAtom_ = parent_->xcb()->call( + displayName_, xsettingsSelectionString, false); + xsettingsAtom_ = parent_->xcb()->call( + displayName_, "_XSETTINGS_SETTINGS", false); + + initScreenEvent_ = parent_->instance()->eventLoop().addTimeEvent( + CLOCK_MONOTONIC, now(CLOCK_MONOTONIC) + 10000, 0, + [this](EventSourceTime *, uint64_t) { + initScreen(); + return true; + }); + initScreenEvent_->setEnabled(false); + + eventHandlers_.emplace_back(parent_->xcb()->call( + name, compMgrAtomString_, + [this](xcb_atom_t) { refreshCompositeManager(); })); + + eventHandlers_.emplace_back( + parent_->xcb()->call( + name, [this](xcb_connection_t *, xcb_generic_event_t *event) { + uint8_t response_type = event->response_type & ~0x80; + switch (response_type) { + case XCB_CLIENT_MESSAGE: { + auto *client_message = + reinterpret_cast(event); + if (client_message->data.data32[1] == compMgrAtom_) { + refreshCompositeManager(); + } else if (client_message->type == managerAtom_ && + client_message->data.data32[1] == + xsettingsSelectionAtom_) { + CLASSICUI_DEBUG() << "Refresh manager"; + refreshManager(); + } + break; + } + case XCB_DESTROY_NOTIFY: { + auto *destroy = + reinterpret_cast(event); + if (destroy->window == xsettingsWindow_) { + refreshManager(); + } + break; + } + case XCB_PROPERTY_NOTIFY: { + auto *property = + reinterpret_cast(event); + if (xsettingsWindow_ && + property->window == xsettingsWindow_) { + readXSettings(); + } + + auto *screen = xcb_aux_get_screen(conn_, defaultScreen_); + if (property->window == screen->root && + property->atom == XCB_ATOM_RESOURCE_MANAGER) { + fontOption_ = forcedDpi(conn_, screen); + } + + break; + } + case XCB_CONFIGURE_NOTIFY: { + auto *configure = + reinterpret_cast(event); + auto *screen = xcb_aux_get_screen(conn_, defaultScreen_); + if (configure->window == screen->root) { + scheduleUpdateScreen(); + } + break; + } + } + if (multiScreen_ == MultiScreenExtension::Randr) { + if (response_type == + xrandrFirstEvent_ + XCB_RANDR_SCREEN_CHANGE_NOTIFY) { + scheduleUpdateScreen(); + } else if (response_type == + xrandrFirstEvent_ + XCB_RANDR_NOTIFY) { + + auto *randr = + reinterpret_cast(event); + if (randr->subCode == XCB_RANDR_NOTIFY_CRTC_CHANGE || + randr->subCode == XCB_RANDR_NOTIFY_OUTPUT_CHANGE) { + scheduleUpdateScreen(); + } + } + } + return false; + })); + + xcb_screen_t *screen = xcb_aux_get_screen(conn_, defaultScreen_); + addEventMaskToWindow(conn_, screen->root, + XCB_EVENT_MASK_STRUCTURE_NOTIFY | + XCB_EVENT_MASK_PROPERTY_CHANGE); + root_ = screen->root; + fontOption_ = forcedDpi(conn_, screen); + CLASSICUI_DEBUG() << "Xft.dpi: " << fontOption_.dpi; + initScreen(); + refreshCompositeManager(); + trayWindow_->initTray(); + refreshManager(); +} + +XCBUI::~XCBUI() { + inputWindow_.reset(); + trayWindow_.reset(); + device_.reset(); +} + +void XCBUI::initScreen() { + auto *screen = xcb_aux_get_screen(conn_, defaultScreen_); + int newScreenCount = xcb_setup_roots_length(xcb_get_setup(conn_)); + if (multiScreen_ == MultiScreenExtension::EXTNone && newScreenCount == 1) { + const xcb_query_extension_reply_t *reply = + xcb_get_extension_data(conn_, &xcb_randr_id); + if (reply && reply->present) { + multiScreen_ = MultiScreenExtension::Randr; + xrandrFirstEvent_ = reply->first_event; + xcb_randr_select_input(conn_, screen->root, + XCB_RANDR_NOTIFY_MASK_SCREEN_CHANGE | + XCB_RANDR_NOTIFY_MASK_OUTPUT_CHANGE | + XCB_RANDR_NOTIFY_MASK_CRTC_CHANGE | + XCB_RANDR_NOTIFY_MASK_OUTPUT_PROPERTY); + } else { + const xcb_query_extension_reply_t *reply = + xcb_get_extension_data(conn_, &xcb_xinerama_id); + + if (reply && reply->present) { + multiScreen_ = MultiScreenExtension::Xinerama; + } + } + } + + maxDpi_ = -1; + rects_.clear(); + if (multiScreen_ == MultiScreenExtension::Randr) { + auto cookie = + xcb_randr_get_screen_resources_current(conn_, screen->root); + auto reply = + makeUniqueCPtr(xcb_randr_get_screen_resources_current_reply( + conn_, cookie, nullptr)); + if (reply) { + xcb_timestamp_t timestamp = 0; + xcb_randr_output_t *outputs = nullptr; + int outputCount = + xcb_randr_get_screen_resources_current_outputs_length( + reply.get()); + + if (outputCount) { + timestamp = reply->config_timestamp; + outputs = + xcb_randr_get_screen_resources_current_outputs(reply.get()); + } else { + auto resourcesCookie = + xcb_randr_get_screen_resources(conn_, screen->root); + auto resourcesReply = + makeUniqueCPtr(xcb_randr_get_screen_resources_reply( + conn_, resourcesCookie, nullptr)); + if (resourcesReply) { + timestamp = resourcesReply->config_timestamp; + outputCount = xcb_randr_get_screen_resources_outputs_length( + resourcesReply.get()); + outputs = xcb_randr_get_screen_resources_outputs( + resourcesReply.get()); + } + } + + if (outputCount) { + auto primaryCookie = + xcb_randr_get_output_primary(conn_, screen->root); + auto primary = + makeUniqueCPtr(xcb_randr_get_output_primary_reply( + conn_, primaryCookie, nullptr)); + if (primary) { + for (int i = 0; i < outputCount; i++) { + auto outputInfoCookie = xcb_randr_get_output_info( + conn_, outputs[i], timestamp); + auto output = + makeUniqueCPtr(xcb_randr_get_output_info_reply( + conn_, outputInfoCookie, nullptr)); + // Invalid, disconnected or disabled output + if (!output) { + continue; + } + + if (output->connection != + XCB_RANDR_CONNECTION_CONNECTED) { + continue; + } + + if (output->crtc == XCB_NONE) { + continue; + } + + auto crtcCookie = xcb_randr_get_crtc_info( + conn_, output->crtc, output->timestamp); + auto crtc = + makeUniqueCPtr(xcb_randr_get_crtc_info_reply( + conn_, crtcCookie, nullptr)); + if (crtc) { + Rect rect; + rect.setPosition(crtc->x, crtc->y); + auto outputWidth = output->mm_width; + auto outputHeight = output->mm_height; + if (crtc->rotation == + XCB_RANDR_ROTATION_ROTATE_90 || + crtc->rotation == + XCB_RANDR_ROTATION_ROTATE_270) { + std::swap(outputWidth, outputHeight); + } + int dpiX = 25.4 * crtc->width / outputWidth; + int dpiY = 25.4 * crtc->height / outputHeight; + rect.setSize(crtc->width, crtc->height); + rects_.emplace_back(rect, std::min(dpiX, dpiY)); + maxDpi_ = std::max(maxDpi_, rects_.back().second); + + if (outputs[i] == primary->output) { + primaryDpi_ = rects_.back().second; + } + } + } + } + } + } + + } else if (multiScreen_ == MultiScreenExtension::Xinerama) { + auto cookie = xcb_xinerama_query_screens(conn_); + if (auto reply = makeUniqueCPtr( + xcb_xinerama_query_screens_reply(conn_, cookie, nullptr))) { + xcb_xinerama_screen_info_iterator_t iter; + for (iter = xcb_xinerama_query_screens_screen_info_iterator( + reply.get()); + iter.rem; xcb_xinerama_screen_info_next(&iter)) { + auto *info = iter.data; + auto x = info->x_org; + auto y = info->y_org; + auto w = info->width; + auto h = info->height; + rects_.emplace_back(Rect().setPosition(x, y).setSize(w, h), -1); + } + } + } + + if (rects_.empty()) { + rects_.emplace_back( + Rect(0, 0, screen->width_in_pixels, screen->height_in_pixels), -1); + } + + screenDpi_ = + 25.4 * screen->height_in_pixels / screen->height_in_millimeters; + + CLASSICUI_DEBUG() << "Screen rects are: " << rects_ + << " Primary DPI: " << primaryDpi_ + << " XScreen DPI: " << screenDpi_; +} + +void XCBUI::refreshCompositeManager() { + auto cookie = xcb_get_selection_owner(conn_, compMgrAtom_); + auto reply = + makeUniqueCPtr(xcb_get_selection_owner_reply(conn_, cookie, nullptr)); + if (reply) { + compMgrWindow_ = reply->owner; + } + + xcb_screen_t *screen = xcb_aux_get_screen(conn_, defaultScreen_); + if (needFreeColorMap_) { + xcb_free_colormap(conn_, colorMap_); + } + + if (compMgrWindow_) { + addEventMaskToWindow(conn_, compMgrWindow_, + XCB_EVENT_MASK_STRUCTURE_NOTIFY); + colorMap_ = xcb_generate_id(conn_); + xcb_create_colormap(conn_, XCB_COLORMAP_ALLOC_NONE, colorMap_, + screen->root, visualId()); + needFreeColorMap_ = true; + } else { + colorMap_ = screen->default_colormap; + needFreeColorMap_ = false; + } + CLASSICUI_DEBUG() << "Refresh color map: " << colorMap_ + << " vid: " << visualId() + << " CompMgr: " << compMgrWindow_; + inputWindow_->createWindow(visualId()); + // mainWindow_->createWindow(); +} + +void XCBUI::refreshManager() { + xcb_grab_server(conn_); + auto cookie = xcb_get_selection_owner(conn_, xsettingsSelectionAtom_); + auto reply = + makeUniqueCPtr(xcb_get_selection_owner_reply(conn_, cookie, nullptr)); + if (reply) { + xsettingsWindow_ = reply->owner; + } + if (xsettingsWindow_) { + addEventMaskToWindow(conn_, xsettingsWindow_, + XCB_EVENT_MASK_STRUCTURE_NOTIFY | + XCB_EVENT_MASK_PROPERTY_CHANGE); + } + xcb_ungrab_server(conn_); + + readXSettings(); +} + +void XCBUI::readXSettings() { + if (!xsettingsWindow_) { + return; + } + + xcb_grab_server(conn_); + int offset = 0; + std::vector data; + bool more = true; + bool error = false; + do { + auto cookie = + xcb_get_property(conn_, false, xsettingsWindow_, xsettingsAtom_, + xsettingsAtom_, offset / 4, 10); + auto reply = + makeUniqueCPtr(xcb_get_property_reply(conn_, cookie, nullptr)); + more = false; + if (reply && reply->format == 8 && reply->type == xsettingsAtom_) { + const auto *start = + static_cast(xcb_get_property_value(reply.get())); + const auto *end = + start + xcb_get_property_value_length(reply.get()); + data.insert(data.end(), start, end); + offset += xcb_get_property_value_length(reply.get()); + more = reply->bytes_after != 0; + } + if (!reply) { + error = true; + } + } while (more); + xcb_ungrab_server(conn_); + + if (error || data.empty()) { + return; + } + auto byteOrder = hostByteOrder(); + if (data[0] != BYTE_ORDER_LSB_FIRST && data[0] != BYTE_ORDER_MSB_FIRST) { + return; + } + + bool needSwap = byteOrder != data[0]; + auto iter = data.cbegin(); + auto readCard32 = [needSwap, &data, &iter](uint32_t *result) { + if (std::distance(iter, data.cend()) < + static_cast(sizeof(uint32_t))) { + return false; + } + uint32_t x = *reinterpret_cast(&(*iter)); + + if (needSwap) { + *result = (x << 24) | ((x & 0xff00) << 8) | ((x & 0xff0000) >> 8) | + (x >> 24); + } else { + *result = x; + } + iter += sizeof(uint32_t); + return true; + }; + auto readCard16 = [needSwap, &data, &iter](uint16_t *result) { + if (std::distance(iter, data.cend()) < + static_cast(sizeof(uint16_t))) { + return false; + } + uint16_t x = *reinterpret_cast(&(*iter)); + + if (needSwap) { + *result = (x << 8) | (x >> 8); + } else { + *result = x; + } + iter += sizeof(uint16_t); + return true; + }; + auto readCard8 = [&data, &iter](uint8_t *result) { + if (std::distance(iter, data.cend()) < + static_cast(sizeof(uint8_t))) { + return false; + } + uint8_t x = *reinterpret_cast(&(*iter)); + *result = x; + iter += sizeof(uint8_t); + return true; + }; + // 1 CARD8 byte-order + // 3 unused + // 4 CARD32 SERIAL + // 4 CARD32 N_SETTINGS + uint32_t dummy; + if (!readCard32(&dummy)) { + return; + } + // SERIAL + if (!readCard32(&dummy)) { + return; + } + + uint32_t nSettings; + if (!readCard32(&nSettings)) { + return; + } + if (static_cast(nSettings) * 8 + 8 > data.size()) { + return; + } + for (uint32_t i = 0; i < nSettings; i++) { + // 1 SETTING_TYPE type + // 1 unused + // 2 n name-len + // n STRING8 name + // P unused, p=pad(n) + // 4 CARD32 last-change-serial + uint8_t type; + if (!readCard8(&type)) { + return; + } + // Valid types are 0,1,2. + if (type > 2) { + return; + } + // Unused + uint8_t dummy8; + if (!readCard8(&dummy8)) { + return; + } + uint16_t nameLen; + if (!readCard16(&nameLen)) { + return; + } +#define XSETTINGS_PAD(n, m) (((n) + (m) - 1) & (~((m) - 1))) + uint32_t namePad = XSETTINGS_PAD(nameLen, 4); + if (std::distance(iter, data.cend()) < namePad) { + return; + } + std::string_view name(&(*iter), nameLen); + iter += namePad; + if (!readCard32(&dummy)) { + return; + } + switch (type) { + case 0: // Integer + if (!readCard32(&dummy)) { + return; + } + break; + case 1: { + // String + uint32_t len; + if (!readCard32(&len)) { + return; + } + uint32_t lenPad = XSETTINGS_PAD(len, 4); + if (std::distance(iter, data.cend()) < lenPad) { + return; + } + std::string_view value(&(*iter), len); + iter += lenPad; + if (name == "Net/IconThemeName" && !value.empty()) { + iconThemeName_ = value; + if (parent()->theme().setIconTheme(iconThemeName_)) { + trayWindow_->update(); + } + } + break; + } + case 2: // Color + // 4 card 16, just do it with 2 card32 + if (!readCard32(&dummy)) { + return; + } + if (!readCard32(&dummy)) { + return; + } + break; + } + } +} + +xcb_visualid_t XCBUI::visualId() const { + xcb_screen_t *screen = xcb_aux_get_screen(conn_, defaultScreen_); + if (compMgrWindow_) { + return findVisual(screen); + } + return screen->root_visual; +} + +void XCBUI::update(UserInterfaceComponent component, + InputContext *inputContext) { + if (component == UserInterfaceComponent::InputPanel) { + inputWindow_->update(inputContext); + } +} + +void XCBUI::updateCursor(InputContext *inputContext) { + inputWindow_->updatePosition(inputContext); +} + +void XCBUI::updateCurrentInputMethod(InputContext *inputContext) { + FCITX_UNUSED(inputContext); + trayWindow_->update(); +} + +int XCBUI::dpiByPosition(int x, int y) { + int shortestDistance = std::numeric_limits::max(); + int screenDpi = -1; + for (const auto &rect : screenRects()) { + int thisDistance = rect.first.distance(x, y); + if (thisDistance < shortestDistance) { + shortestDistance = thisDistance; + screenDpi = rect.second; + } + } + + return scaledDPI(screenDpi); +} + +int XCBUI::scaledDPI(int dpi) { + if (!*parent_->config().perScreenDPI || + parent_->xcb()->call(displayName_)) { + // CLASSICUI_DEBUG() << "Use font option dpi: " << fontOption_.dpi; + if (fontOption_.dpi > 0) { + return fontOption_.dpi; + } + if (screenDpi_ >= 96) { + // Nowadays their should not be tiny dpi screen I assume. + // In ancient days, there used to be invalid DPI value that make + // font extremely tiny. + return screenDpi_; + } + return -1; + } + if (dpi < 0) { + return fontOption_.dpi; + } + + double targetDPI; + auto baseScreenDPI = primaryDpi_ > 0 ? primaryDpi_ : maxDpi_; + auto baseDPI = fontOption_.dpi > 0 ? fontOption_.dpi : screenDpi_; + targetDPI = (static_cast(dpi) / baseScreenDPI) * baseDPI; + double scale = targetDPI / 96; + if (scale < 1) { + targetDPI = 96; + } + return targetDPI; +} + +void XCBUI::resume() {} + +void XCBUI::suspend() { + inputWindow_->update(nullptr); + trayWindow_->suspend(); +} + +void XCBUI::setEnableTray(bool enable) { + bool enableTray = enable && !parent_->suspended(); + if (enableTray) { + trayWindow_->resume(); + } else { + trayWindow_->suspend(); + } +} + +void XCBUI::scheduleUpdateScreen() { + initScreenEvent_->setNextInterval(100000); + initScreenEvent_->setOneShot(); +} + +bool XCBUI::grabPointer(XCBWindow *window) { + auto cookie = xcb_grab_pointer( + conn_, false, window->wid(), + (XCB_EVENT_MASK_BUTTON_PRESS | XCB_EVENT_MASK_BUTTON_RELEASE | + XCB_EVENT_MASK_BUTTON_MOTION | XCB_EVENT_MASK_ENTER_WINDOW | + XCB_EVENT_MASK_LEAVE_WINDOW | XCB_EVENT_MASK_POINTER_MOTION), + XCB_GRAB_MODE_ASYNC, XCB_GRAB_MODE_ASYNC, XCB_WINDOW_NONE, + XCB_CURSOR_NONE, XCB_TIME_CURRENT_TIME); + + auto reply = makeUniqueCPtr(xcb_grab_pointer_reply(conn_, cookie, nullptr)); + if (reply && reply->status == XCB_GRAB_STATUS_SUCCESS) { + pointerGrabber_ = window; + return true; + } + return false; +} + +void XCBUI::ungrabPointer() { + if (pointerGrabber_) { + xcb_ungrab_pointer(conn_, XCB_TIME_CURRENT_TIME); + pointerGrabber_ = nullptr; + } +} + +void XCBUI::destroyCairoDevice(cairo_device_t *device) { + if (!device) { + return; + } + // Here's how cairo-xcb works. + // When a new xcb connection is seen, a internal shared xcb device is + // created and referenced by cairo itself. When device_finish is called, + // this internal shared reference is decreased, and will be destroyed when + // ref is 0. So while it looks like we reference and dereference which + // should does nothing, the cairo_device_finish will to the actual work to + // clean up the internal reference. See also: + // https://lists.cairographics.org/archives/cairo/2018-November/028791.html + // Such design is to allow xcb shared connection data to be kept even if + // there is no xcb surface. Though this API design really sucks, since there + // is no API to create a xcb device and pass it to cairo_xcb_surface_create. + // Instead, we have to get the device pointer from a xcb surface. + cairo_device_finish(device); + cairo_device_destroy(device); +} + +void XCBUI::setCairoDevice(cairo_device_t *device) { + if (device_.get() != device) { + device_.reset(); + device_.reset(cairo_device_reference(device)); + } +} + +} // namespace fcitx::m3ui diff --git a/src/xcbui.h b/src/xcbui.h new file mode 100644 index 0000000..a203fec --- /dev/null +++ b/src/xcbui.h @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_XCBUI_H_ +#define _FCITX_UI_CLASSIC_XCBUI_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/eventloopinterface.h" +#include "fcitx-utils/handlertable_details.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "fcitx/userinterface.h" +#include "classicui.h" + +namespace fcitx::m3ui { + +class XCBInputWindow; +class XCBTrayWindow; + +enum class MultiScreenExtension { Randr, Xinerama, EXTNone }; + +enum class XCBHintStyle { Default, NoHint, Medium, Slight, Full }; + +enum class XCBRGBA { Default, NoRGBA, RGB, BGR, VRGB, VBGR }; + +class XCBWindow; + +struct XCBFontOption { + int dpi = -1; + bool antialias = true; + XCBHintStyle hint = XCBHintStyle::Default; + XCBRGBA rgba = XCBRGBA::Default; + + void setupPangoContext(PangoContext *context) const; +}; + +class XCBUI : public UIInterface { +public: + XCBUI(M3UI *parent, const std::string &name, xcb_connection_t *conn, + int defaultScreen); + ~XCBUI(); + + M3UI *parent() const { return parent_; } + const std::string &displayName() const { return displayName_; } + xcb_connection_t *connection() const { return conn_; } + xcb_ewmh_connection_t *ewmh() const { return ewmh_; } + xcb_window_t root() const { return root_; } + int defaultScreen() const { return defaultScreen_; } + xcb_colormap_t colorMap() const { return colorMap_; } + xcb_visualid_t visualId() const; + void update(UserInterfaceComponent component, + InputContext *inputContext) override; + void updateCursor(InputContext *inputContext) override; + void updateCurrentInputMethod(InputContext *inputContext) override; + void suspend() override; + void resume() override; + void setEnableTray(bool enable) override; + void setCairoDevice(cairo_device_t *device); + + const auto &screenRects() { return rects_; } + int dpiByPosition(int x, int y); + int scaledDPI(int dpi); + const XCBFontOption &fontOption() const { return fontOption_; } + + bool grabPointer(XCBWindow *window); + void ungrabPointer(); + XCBWindow *pointerGrabber() const { return pointerGrabber_; } + +private: + void refreshCompositeManager(); + void refreshManager(); + void readXSettings(); + void initScreen(); + void scheduleUpdateScreen(); + + static void destroyCairoDevice(cairo_device_t *device); + + M3UI *parent_; + UniqueCPtr device_; + std::string displayName_; + xcb_connection_t *conn_; + XCBWindow *pointerGrabber_ = nullptr; + xcb_window_t root_ = XCB_WINDOW_NONE; + xcb_ewmh_connection_t *ewmh_; + int defaultScreen_; + xcb_colormap_t colorMap_; + bool needFreeColorMap_ = false; + std::unique_ptr inputWindow_; + std::unique_ptr trayWindow_; + + std::string iconThemeName_; + + std::string compMgrAtomString_; + xcb_atom_t compMgrAtom_ = XCB_ATOM_NONE; + xcb_window_t compMgrWindow_ = XCB_WINDOW_NONE; + + xcb_atom_t managerAtom_ = XCB_ATOM_NONE; + xcb_atom_t xsettingsSelectionAtom_ = XCB_ATOM_NONE; + xcb_atom_t xsettingsWindow_ = XCB_WINDOW_NONE; + xcb_atom_t xsettingsAtom_ = XCB_ATOM_NONE; + + XCBFontOption fontOption_; + int maxDpi_ = -1; + int primaryDpi_ = -1; + int screenDpi_ = 96; + MultiScreenExtension multiScreen_ = MultiScreenExtension::EXTNone; + int xrandrFirstEvent_ = 0; + std::unique_ptr initScreenEvent_; + + std::vector> rects_; + + std::vector> eventHandlers_; +}; + +void addEventMaskToWindow(xcb_connection_t *conn, xcb_window_t wid, + uint32_t mask); +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_XCBUI_H_ diff --git a/src/xcbwindow.cpp b/src/xcbwindow.cpp new file mode 100644 index 0000000..3e184d8 --- /dev/null +++ b/src/xcbwindow.cpp @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ + +#include "xcbwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "common.h" +#include "window.h" +#include "xcb_public.h" +#include "xcbui.h" + +namespace fcitx::m3ui { + +XCBWindow::XCBWindow(XCBUI *ui, int width, int height) : ui_(ui) { + Window::resize(width, height); +} + +XCBWindow::~XCBWindow() { destroyWindow(); } + +void XCBWindow::createWindow(xcb_visualid_t vid, bool overrideRedirect) { + auto *conn = ui_->connection(); + + if (wid_) { + destroyWindow(); + } + xcb_screen_t *screen = xcb_aux_get_screen(conn, ui_->defaultScreen()); + + xcb_colormap_t colorMap; + CLASSICUI_DEBUG() << "Create window with vid: " << vid; + if (vid == ui_->visualId()) { + colorMap = ui_->colorMap(); + colorMapNeedFree_ = 0; + CLASSICUI_DEBUG() << "Use shared color map: " << colorMap; + } else if (vid) { + colorMapNeedFree_ = xcb_generate_id(conn); + xcb_create_colormap(conn, XCB_COLORMAP_ALLOC_NONE, colorMapNeedFree_, + screen->root, vid); + colorMap = colorMapNeedFree_; + CLASSICUI_DEBUG() << "Use new color map: " << colorMapNeedFree_; + } else { + colorMapNeedFree_ = 0; + colorMap = XCB_COPY_FROM_PARENT; + CLASSICUI_DEBUG() << "Use color map copy from parent"; + } + + wid_ = xcb_generate_id(conn); + + auto depth = xcb_aux_get_depth_of_visual(screen, vid); + + uint32_t valueMask = XCB_CW_BACK_PIXEL | XCB_CW_BORDER_PIXEL | + XCB_CW_BIT_GRAVITY | XCB_CW_BACKING_STORE | + XCB_CW_OVERRIDE_REDIRECT | XCB_CW_SAVE_UNDER | + XCB_CW_COLORMAP; + + if (overrideRedirect) { + valueMask |= XCB_CW_OVERRIDE_REDIRECT; + } + + xcb_params_cw_t params; + memset(¶ms, 0, sizeof(params)); + params.back_pixel = 0; + params.border_pixel = 0; + params.bit_gravity = XCB_GRAVITY_NORTH_WEST; + params.backing_store = XCB_BACKING_STORE_WHEN_MAPPED; + params.override_redirect = overrideRedirect ? 1 : 0; + params.save_under = 1; + params.colormap = colorMap; + vid_ = vid; + + physicalWidth_ = physicalSizeFromLogical(width_); + physicalHeight_ = physicalSizeFromLogical(height_); + + auto cookie = xcb_aux_create_window_checked( + conn, depth, wid_, screen->root, 0, 0, physicalWidth_, physicalHeight_, + 0, XCB_WINDOW_CLASS_INPUT_OUTPUT, vid, valueMask, ¶ms); + if (auto error = makeUniqueCPtr(xcb_request_check(conn, cookie))) { + CLASSICUI_DEBUG() << "Create window failed: " + << static_cast(error->error_code) << " " << vid + << " " << colorMap; + } else { + CLASSICUI_DEBUG() << "Window created id: " << wid_; + } + constexpr uint32_t XEMBED_VERSION = 0; + constexpr uint32_t XEMBED_MAPPED = (1 << 0); + uint32_t data[] = {XEMBED_VERSION, XEMBED_MAPPED}; + xcb_atom_t _XEMBED_INFO = ui_->parent()->xcb()->call( + ui_->displayName(), "_XEMBED_INFO", false); + xcb_change_property(conn, XCB_PROP_MODE_REPLACE, wid_, _XEMBED_INFO, + _XEMBED_INFO, 32, 2, data); + + eventFilter_ = ui_->parent()->xcb()->call( + ui_->displayName(), + [this](xcb_connection_t *, xcb_generic_event_t *event) { + return filterEvent(event); + }); + + surface_.reset(cairo_xcb_surface_create( + conn, wid_, + vid ? xcb_aux_find_visual_by_id(screen, vid) + : xcb_aux_find_visual_by_id(screen, screen->root_visual), + physicalWidth_, physicalHeight_)); + if (surface_) { + cairo_surface_set_device_scale(surface_.get(), scale_, scale_); + ui_->setCairoDevice(cairo_surface_get_device(surface_.get())); + } + contentSurface_.reset(); + + postCreateWindow(); +} + +void XCBWindow::destroyWindow() { + auto *conn = ui_->connection(); + eventFilter_.reset(); + if (wid_) { + xcb_destroy_window(conn, wid_); + wid_ = 0; + } + if (colorMapNeedFree_) { + xcb_free_colormap(conn, colorMapNeedFree_); + colorMapNeedFree_ = 0; + } + + if (ui_->pointerGrabber() == this) { + ui_->ungrabPointer(); + } +} + +void XCBWindow::setScale(double scale) { + if (scale_ != scale && scale > 0) { + scale_ = scale; + cairo_surface_set_device_scale(surface_.get(), scale_, scale_); + } +} + +void XCBWindow::resize(unsigned int width, unsigned int height) { + auto newPhysicalWidth = physicalSizeFromLogical(width); + auto newPhysicalHeight = physicalSizeFromLogical(height); + if (newPhysicalWidth != physicalWidth_ || + newPhysicalHeight != physicalHeight_) { + const uint32_t vals[2] = {static_cast(newPhysicalWidth), + static_cast(newPhysicalHeight)}; + xcb_configure_window(ui_->connection(), wid_, + XCB_CONFIG_WINDOW_WIDTH | XCB_CONFIG_WINDOW_HEIGHT, + vals); + cairo_xcb_surface_set_size(surface_.get(), newPhysicalWidth, + newPhysicalHeight); + physicalWidth_ = newPhysicalWidth; + physicalHeight_ = newPhysicalHeight; + } + Window::resize(width, height); + CLASSICUI_DEBUG() << "Resize: " << width << " " << height << " scale " + << scale_ << " physical " << physicalWidth_ << " " + << physicalHeight_; +} + +cairo_surface_t *XCBWindow::prerender() { +#if 1 + contentSurface_.reset(cairo_surface_create_similar_image( + surface_.get(), CAIRO_FORMAT_ARGB32, physicalWidth_, physicalHeight_)); +#else + contentSurface_.reset(cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, physicalWidth_, physicalHeight_)); +#endif + cairo_surface_set_device_scale(contentSurface_.get(), scale_, scale_); + return contentSurface_.get(); +} + +void XCBWindow::render() { + auto *cr = cairo_create(surface_.get()); + cairo_set_operator(cr, CAIRO_OPERATOR_SOURCE); + cairo_set_source_surface(cr, contentSurface_.get(), 0, 0); + cairo_paint(cr); + cairo_destroy(cr); + CLASSICUI_DEBUG() << "Render"; +} + +double XCBWindow::logicalFromPhysical(double value) const { + return std::floor(value / scale_); +} + +double XCBWindow::physicalFromLogical(double value) const { + return std::round(value * scale_); +} + +int XCBWindow::physicalSizeFromLogical(double size) const { + return std::max(1.0, physicalFromLogical(size)); +} + +Rect XCBWindow::physicalFromLogical(const Rect &rect) const { + return Rect() + .setLeft(physicalFromLogical(rect.left())) + .setTop(physicalFromLogical(rect.top())) + .setRight(physicalFromLogical(rect.right())) + .setBottom(physicalFromLogical(rect.bottom())); +} + +double XCBWindow::scaleForDPI(int dpi) { + if (dpi <= 0) { + return 1.0; + } + return static_cast(dpi) / 96.0; +} + +} // namespace fcitx::m3ui diff --git a/src/xcbwindow.h b/src/xcbwindow.h new file mode 100644 index 0000000..c95684d --- /dev/null +++ b/src/xcbwindow.h @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2016-2016 CSSlayer + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + */ +#ifndef _FCITX_UI_CLASSIC_XCBWINDOW_H_ +#define _FCITX_UI_CLASSIC_XCBWINDOW_H_ + +#include +#include +#include +#include +#include "fcitx-utils/handlertable.h" +#include "fcitx-utils/misc.h" +#include "fcitx-utils/rect.h" +#include "window.h" +#include "xcb_public.h" +#include "xcbui.h" + +namespace fcitx::m3ui { + +class XCBWindow : public Window { +public: + XCBWindow(XCBUI *ui, int width = 1, int height = 1); + ~XCBWindow(); + + void createWindow(xcb_visualid_t vid, bool overrideRedirect = true); + virtual void postCreateWindow() {} + void destroyWindow(); + void resize(unsigned int width, unsigned int height) override; + + cairo_surface_t *prerender() override; + void render() override; + + virtual bool filterEvent(xcb_generic_event_t *event) = 0; + + xcb_window_t wid() const { return wid_; } + void setScale(double scale); + +protected: + double logicalFromPhysical(double value) const; + double physicalFromLogical(double value) const; + // X11 window dimensions must be at least one pixel. + int physicalSizeFromLogical(double size) const; + Rect physicalFromLogical(const Rect &rect) const; + static double scaleForDPI(int dpi); + + XCBUI *ui_; + xcb_window_t wid_ = 0; + xcb_colormap_t colorMapNeedFree_ = 0; + xcb_visualid_t vid_ = 0; + std::unique_ptr> eventFilter_; + UniqueCPtr surface_; + UniqueCPtr contentSurface_; + int physicalWidth_ = 1; + int physicalHeight_ = 1; + double scale_ = 1.0; +}; + +} // namespace fcitx::m3ui + +#endif // _FCITX_UI_CLASSIC_XCBWINDOW_H_ diff --git a/themes/m3ui-dark/arrow.svg b/themes/m3ui-dark/arrow.svg new file mode 100644 index 0000000..8cb843c --- /dev/null +++ b/themes/m3ui-dark/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-dark/highlight.svg b/themes/m3ui-dark/highlight.svg new file mode 100644 index 0000000..09b7bf6 --- /dev/null +++ b/themes/m3ui-dark/highlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-dark/next.svg b/themes/m3ui-dark/next.svg new file mode 100644 index 0000000..d8eddd1 --- /dev/null +++ b/themes/m3ui-dark/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-dark/panel.svg b/themes/m3ui-dark/panel.svg new file mode 100644 index 0000000..25610f6 --- /dev/null +++ b/themes/m3ui-dark/panel.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-dark/prev.svg b/themes/m3ui-dark/prev.svg new file mode 100644 index 0000000..78f4de3 --- /dev/null +++ b/themes/m3ui-dark/prev.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-dark/radio.svg b/themes/m3ui-dark/radio.svg new file mode 100644 index 0000000..91a3b96 --- /dev/null +++ b/themes/m3ui-dark/radio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/themes/m3ui-dark/theme.conf b/themes/m3ui-dark/theme.conf new file mode 100644 index 0000000..233ca4c --- /dev/null +++ b/themes/m3ui-dark/theme.conf @@ -0,0 +1,117 @@ +[Metadata] +Name=Material 3 Dark (m3ui) +Version=1 +Author=m3ui +Description=Material 3 theme for the m3ui Fcitx5 user interface +ScaleWithDPI=True + +[InputPanel] +SurfaceColor=#2F282AF5 +OutlineColor=#FFFFFF1F +HighlightFillColor=#5E3A49FF +PanelRadius=18 +ItemRadius=12 +NormalColor=#EBE0E1 +HighlightCandidateColor=#FFD9E2 +HighlightColor=#FFB0C9 +HighlightBackgroundColor=#5E3A49 +CandidateLabelColor=#A8969B +HighlightCandidateLabelColor=#E6BDC9 +CandidateCommentColor=#D5C2C6 +HighlightCandidateCommentColor=#FFD9E2 +LabelTextSizeFactor=78 +CommentTextSizeFactor=78 +FullWidthHighlight=False +PageButtonAlignment=Center + +[InputPanel/ContentMargin] +Left=3 +Right=3 +Top=3 +Bottom=3 + +[InputPanel/TextMargin] +Left=8 +Right=8 +Top=5 +Bottom=5 + +[InputPanel/Background] +Image=panel.svg + +[InputPanel/Background/Margin] +Left=12 +Right=12 +Top=12 +Bottom=12 + +[InputPanel/Highlight] +Image=highlight.svg + +[InputPanel/Highlight/Margin] +Left=10 +Right=10 +Top=10 +Bottom=10 + +[InputPanel/PrevPage] +Image=prev.svg + +[InputPanel/PrevPage/ClickMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[InputPanel/NextPage] +Image=next.svg + +[InputPanel/NextPage/ClickMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[Menu] +NormalColor=#EBE0E1 +HighlightCandidateColor=#FFD9E2 +Spacing=2 + +[Menu/Background] +Image=panel.svg + +[Menu/Background/Margin] +Left=12 +Right=12 +Top=12 +Bottom=12 + +[Menu/ContentMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[Menu/Highlight] +Image=highlight.svg + +[Menu/Highlight/Margin] +Left=10 +Right=10 +Top=10 +Bottom=10 + +[Menu/Separator] +Color=#514347 + +[Menu/CheckBox] +Image=radio.svg + +[Menu/SubMenu] +Image=arrow.svg + +[Menu/TextMargin] +Left=8 +Right=8 +Top=5 +Bottom=5 diff --git a/themes/m3ui-light/arrow.svg b/themes/m3ui-light/arrow.svg new file mode 100644 index 0000000..8cb843c --- /dev/null +++ b/themes/m3ui-light/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-light/highlight.svg b/themes/m3ui-light/highlight.svg new file mode 100644 index 0000000..09b7bf6 --- /dev/null +++ b/themes/m3ui-light/highlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-light/next.svg b/themes/m3ui-light/next.svg new file mode 100644 index 0000000..d8eddd1 --- /dev/null +++ b/themes/m3ui-light/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-light/panel.svg b/themes/m3ui-light/panel.svg new file mode 100644 index 0000000..25610f6 --- /dev/null +++ b/themes/m3ui-light/panel.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-light/prev.svg b/themes/m3ui-light/prev.svg new file mode 100644 index 0000000..78f4de3 --- /dev/null +++ b/themes/m3ui-light/prev.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/m3ui-light/radio.svg b/themes/m3ui-light/radio.svg new file mode 100644 index 0000000..91a3b96 --- /dev/null +++ b/themes/m3ui-light/radio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/themes/m3ui-light/theme.conf b/themes/m3ui-light/theme.conf new file mode 100644 index 0000000..41132ec --- /dev/null +++ b/themes/m3ui-light/theme.conf @@ -0,0 +1,117 @@ +[Metadata] +Name=Material 3 Light (m3ui) +Version=1 +Author=m3ui +Description=Material 3 theme for the m3ui Fcitx5 user interface +ScaleWithDPI=True + +[InputPanel] +SurfaceColor=#FFF8F7F5 +OutlineColor=#00000026 +HighlightFillColor=#FFD9E2FF +PanelRadius=18 +ItemRadius=12 +NormalColor=#231A1D +HighlightCandidateColor=#3E001D +HighlightColor=#8B4A62 +HighlightBackgroundColor=#FFD9E2 +CandidateLabelColor=#85737A +HighlightCandidateLabelColor=#6E3A4F +CandidateCommentColor=#52434A +HighlightCandidateCommentColor=#5A2C42 +LabelTextSizeFactor=78 +CommentTextSizeFactor=78 +FullWidthHighlight=False +PageButtonAlignment=Center + +[InputPanel/ContentMargin] +Left=3 +Right=3 +Top=3 +Bottom=3 + +[InputPanel/TextMargin] +Left=8 +Right=8 +Top=5 +Bottom=5 + +[InputPanel/Background] +Image=panel.svg + +[InputPanel/Background/Margin] +Left=12 +Right=12 +Top=12 +Bottom=12 + +[InputPanel/Highlight] +Image=highlight.svg + +[InputPanel/Highlight/Margin] +Left=10 +Right=10 +Top=10 +Bottom=10 + +[InputPanel/PrevPage] +Image=prev.svg + +[InputPanel/PrevPage/ClickMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[InputPanel/NextPage] +Image=next.svg + +[InputPanel/NextPage/ClickMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[Menu] +NormalColor=#231A1D +HighlightCandidateColor=#3E001D +Spacing=2 + +[Menu/Background] +Image=panel.svg + +[Menu/Background/Margin] +Left=12 +Right=12 +Top=12 +Bottom=12 + +[Menu/ContentMargin] +Left=4 +Right=4 +Top=4 +Bottom=4 + +[Menu/Highlight] +Image=highlight.svg + +[Menu/Highlight/Margin] +Left=10 +Right=10 +Top=10 +Bottom=10 + +[Menu/Separator] +Color=#514347 + +[Menu/CheckBox] +Image=radio.svg + +[Menu/SubMenu] +Image=arrow.svg + +[Menu/TextMargin] +Left=8 +Right=8 +Top=5 +Bottom=5