diff --git a/tuhigui/.gitignore b/tuhigui/.gitignore new file mode 100644 index 0000000..cc2165d --- /dev/null +++ b/tuhigui/.gitignore @@ -0,0 +1,4 @@ +*~ +*.swp +__pycache__ +.flatpak-builder diff --git a/tuhigui/COPYING b/tuhigui/COPYING new file mode 100644 index 0000000..3912109 --- /dev/null +++ b/tuhigui/COPYING @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, 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. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, 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 or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +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 give any other recipients of the Program a copy of this License +along with the Program. + +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 Program or any portion +of it, thus forming a work based on the Program, 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) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +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 Program, 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 Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) 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; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, 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 executable. However, as a +special exception, the source code 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. + +If distribution of executable or 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 counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program 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. + + 5. 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 Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program 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 to +this License. + + 7. 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 Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program 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 Program. + +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. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program 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. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 Program +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 Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, 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 + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "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 PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. 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 PROGRAM 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 PROGRAM (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 PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), 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 Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. 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 program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/tuhigui/README.md b/tuhigui/README.md new file mode 100644 index 0000000..929fdb5 --- /dev/null +++ b/tuhigui/README.md @@ -0,0 +1,50 @@ +TuhiGui +======= + +Tuhi is a GUI to the Tuhi DBus daemon that connects to and fetches the data +from the Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). The data +is converted to SVG and users can save it on disk. + +For more info about Tuhi see: https://github.com/tuhiproject/tuhi + + +Building TuhiGUI +---------------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> meson builddir + $> ninja -C builddir + $> ./builddir/tuhigui.devel +``` + +TuhiGui requires Python v3.6 or above. + +Install TuhiGUI +--------------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> meson builddir + $> ninja -C builddir install + $> tuhigui +``` + +TuhiGui requires Python v3.6 or above. + +Flatpak +------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> flatpak-builder flatpak_builddir org.freedesktop.TuhiGui.json --install --user --force-clean + $> flatpak run org.freedesktop.TuhiGui +``` + +License +------- + +TuhiGui is licensed under the GPLv2 or later. diff --git a/tuhigui/data/meson.build b/tuhigui/data/meson.build new file mode 100644 index 0000000..981220d --- /dev/null +++ b/tuhigui/data/meson.build @@ -0,0 +1,42 @@ +gnome = import('gnome') + +desktopdir = join_paths(datadir, 'applications') +icondir = join_paths(datadir, 'icons', 'hicolor', 'scalable', 'apps') +metainfodir = join_paths(datadir, 'metainfo') + +conf = configuration_data() +conf.set('version', meson.project_version()) +conf.set('url', 'https://github.com/tuhiproject/tuhigui') +conf.set('version_date', version_date) + +about_dialog = configure_file(input: 'ui/AboutDialog.ui.in', + output: 'AboutDialog.ui', + configuration: conf) + +install_data('org.freedesktop.TuhiGui.svg', install_dir: icondir) + +i18n.merge_file(input: 'org.freedesktop.TuhiGui.desktop.in', + output: 'org.freedesktop.TuhiGui.desktop', + type: 'desktop', + po_dir: podir, + install: true, + install_dir: desktopdir) + +appdata = configure_file(input: 'org.freedesktop.TuhiGui.appdata.xml.in.in', + output: 'org.freedesktop.TuhiGui.appdata.xml.in', + configuration: conf) + +i18n.merge_file(input: appdata, + output: 'org.freedesktop.TuhiGui.appdata.xml', + type: 'xml', + po_dir: podir, + install: true, + install_dir: metainfodir) + + +gnome.compile_resources('tuhigui', 'tuhigui.gresource.xml', + source_dir: '.', + dependencies: [about_dialog], + gresource_bundle: true, + install: true, + install_dir: pkgdatadir) diff --git a/tuhigui/data/org.freedesktop.TuhiGui.appdata.xml.in.in b/tuhigui/data/org.freedesktop.TuhiGui.appdata.xml.in.in new file mode 100644 index 0000000..63d8ea1 --- /dev/null +++ b/tuhigui/data/org.freedesktop.TuhiGui.appdata.xml.in.in @@ -0,0 +1,59 @@ + + + org.freedesktop.TuhiGui + FSFAP + GPL-2.0+ + + TuhiGui + Utility to download drawings from the Wacom Ink range of devices + +

+ TuhiGui is a graphical user interface to download drawings stored on + tablet devices from the Wacom Ink range, e.g. Intuos Pro Paper or + Bamboo Slate. +

+

+ TuhiGui requires Tuhi, the daemon to actually communicate with the + devices. ThiGui is merely a front end to Tuhi, Tuhi must be + installed and running when TuhiGui is launched. +

+
+ + + AppMenu + HiDpiIcon + ModernToolkit + + + org.freedesktop.TuhiGui.desktop + + + + The button configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-buttonpage.png + + + The LED configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-ledpage.png + + + The resolution configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-resolutionpage.png + + + + https://github.com/tuhiproject/tuhigui/ + https://github.com/tuhiproject/tuhigui/issues + https://github.com/tuhiproject/tuhigui/wiki + GNOME + + tuhigui + + + tuhigui + + + + + +
diff --git a/tuhigui/data/org.freedesktop.TuhiGui.desktop.in b/tuhigui/data/org.freedesktop.TuhiGui.desktop.in new file mode 100644 index 0000000..286fed6 --- /dev/null +++ b/tuhigui/data/org.freedesktop.TuhiGui.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=TuhiGui +Comment=Utility to download drawings from the Wacom Ink range of devices +Exec=tuhigui +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=org.freedesktop.TuhiGui +Type=Application +StartupNotify=true +Categories=GTK;GNOME;Utility; +# Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! +Keywords=tablet;wacom;ink; diff --git a/tuhigui/data/org.freedesktop.TuhiGui.svg b/tuhigui/data/org.freedesktop.TuhiGui.svg new file mode 100644 index 0000000..5c1e883 --- /dev/null +++ b/tuhigui/data/org.freedesktop.TuhiGui.svg @@ -0,0 +1,124 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/tuhigui/data/tuhigui.gresource.xml b/tuhigui/data/tuhigui.gresource.xml new file mode 100644 index 0000000..06f0d6e --- /dev/null +++ b/tuhigui/data/tuhigui.gresource.xml @@ -0,0 +1,11 @@ + + + + AboutDialog.ui + ui/Drawing.ui + ui/DrawingPerspective.ui + ui/MainWindow.ui + ui/SetupPerspective.ui + ui/ErrorPerspective.ui + + diff --git a/tuhigui/data/ui/AboutDialog.ui.in b/tuhigui/data/ui/AboutDialog.ui.in new file mode 100644 index 0000000..eef6e43 --- /dev/null +++ b/tuhigui/data/ui/AboutDialog.ui.in @@ -0,0 +1,43 @@ + + + + + + False + True + normal + TuhiGUI + @version@ + Copyright © 2019 Tuhi Developers + @url@ + Visit Tuhi’s website + Maintainers: + Peter Hutterer <peter.hutterer@who-t.net> + + +Contributors: + + Peter Hutterer <peter.hutterer@who-t.net> + translator-credits + org.freedesktop.TuhiGui + gpl-2-0 + + + False + + + False + + + False + False + 0 + + + + + + + + + diff --git a/tuhigui/data/ui/Drawing.ui b/tuhigui/data/ui/Drawing.ui new file mode 100644 index 0000000..a3b820b --- /dev/null +++ b/tuhigui/data/ui/Drawing.ui @@ -0,0 +1,148 @@ + + + + + + True + False + document-save-as-symbolic + + + True + False + edit-delete-symbolic + + + True + False + object-rotate-left-symbolic + + + True + False + object-rotate-right-symbolic + + + diff --git a/tuhigui/data/ui/DrawingPerspective.ui b/tuhigui/data/ui/DrawingPerspective.ui new file mode 100644 index 0000000..3acee45 --- /dev/null +++ b/tuhigui/data/ui/DrawingPerspective.ui @@ -0,0 +1,219 @@ + + + + + + diff --git a/tuhigui/data/ui/ErrorPerspective.ui b/tuhigui/data/ui/ErrorPerspective.ui new file mode 100644 index 0000000..b41965d --- /dev/null +++ b/tuhigui/data/ui/ErrorPerspective.ui @@ -0,0 +1,117 @@ + + + + + + diff --git a/tuhigui/data/ui/MainWindow.ui b/tuhigui/data/ui/MainWindow.ui new file mode 100644 index 0000000..4a50908 --- /dev/null +++ b/tuhigui/data/ui/MainWindow.ui @@ -0,0 +1,47 @@ + + + + + + diff --git a/tuhigui/data/ui/SetupPerspective.ui b/tuhigui/data/ui/SetupPerspective.ui new file mode 100644 index 0000000..a40e95a --- /dev/null +++ b/tuhigui/data/ui/SetupPerspective.ui @@ -0,0 +1,275 @@ + + + + + + diff --git a/tuhigui/meson.build b/tuhigui/meson.build new file mode 100644 index 0000000..72c2e22 --- /dev/null +++ b/tuhigui/meson.build @@ -0,0 +1,111 @@ +project('tuhigui', + version: '0.1', + license: 'GPLv2', + meson_version: '>= 0.48.0') +# The tag date of the project_version(), update when the version bumps. +version_date='2019-07-10' +# Note: Update the Contributor list in data/ui/AboutDialog.ui.in when the +# version bumps + +# Dependencies +dependency('python3', required: true) +dependency('pygobject-3.0', required: true) + +# Gtk version required +gtk_major_version = 3 +gtk_minor_version = 22 + +prefix = get_option('prefix') +datadir = join_paths(prefix, get_option('datadir')) +localedir = join_paths(prefix, get_option('localedir')) +pkgdatadir = join_paths(datadir, meson.project_name()) +bindir = join_paths(prefix, get_option('bindir')) +podir = join_paths(meson.source_root(), 'po') +desktopdir = join_paths(datadir, 'applications') +icondir = join_paths(datadir, 'icons', 'hicolor', 'scalable', 'apps') +metainfodir = join_paths(datadir, 'metainfo') + +i18n = import('i18n') + +subdir('data') +subdir('po') + +# Find the directory to install our Python code +pymod = import('python') +py3 = pymod.find_installation() +python_dir = py3.get_install_dir() +install_subdir('tuhigui', install_dir: python_dir, exclude_directories: '__pycache__') + +config_tuhigui = configuration_data() +config_tuhigui.set('pkgdatadir', pkgdatadir) +config_tuhigui.set('localedir', localedir) +config_tuhigui.set('gtk_major_version', gtk_major_version) +config_tuhigui.set('gtk_minor_version', gtk_minor_version) +config_tuhigui.set('devel', '') + +config_tuhigui_devel = config_tuhigui +config_tuhigui_devel.set('pkgdatadir', join_paths(meson.build_root(), 'data')) +config_tuhigui_devel.set('localedir', join_paths(meson.build_root(), 'po')) +config_tuhigui_devel.set('devel', ''' +sys.path.insert(1, '@0@') +print('Running from source tree, using local files') +'''.format(meson.source_root())) + +configure_file(input: 'tuhigui.in', + output: 'tuhigui', + configuration: config_tuhigui, + install_dir: bindir) + +configure_file(input: 'tuhigui.in', + output: 'tuhigui.devel', + configuration: config_tuhigui_devel) + +meson.add_install_script('meson_install.sh') + +desktop_file = i18n.merge_file(input: 'data/org.freedesktop.TuhiGui.desktop.in', + output: 'org.freedesktop.TuhiGui.desktop', + type: 'desktop', + po_dir: podir, + install: true, + install_dir: desktopdir) + +conf = configuration_data() +conf.set('version', meson.project_version()) +conf.set('url', 'https://github.com/tuhiproject/tuhigui') +conf.set('version_date', version_date) + +appdata_intl = configure_file(input: 'data/org.freedesktop.TuhiGui.appdata.xml.in.in', + output: 'org.freedesktop.TuhiGui.appdata.xml.in', + configuration: conf) + +appdata = i18n.merge_file(input: appdata_intl, + output: 'org.freedesktop.TuhiGui.appdata.xml', + type: 'xml', + po_dir: podir, + install: true, + install_dir: metainfodir) + +install_data('data/org.freedesktop.TuhiGui.svg', install_dir: icondir) + +flake8 = find_program('flake8-3', required: false) +if flake8.found() + test('flake8', flake8, + args: ['--ignore=E501,W504', + join_paths(meson.source_root(), 'tuhigui/')]) +endif + +desktop_validate = find_program('desktop-file-validate', required: false) +if desktop_validate.found() + test('desktop-file-validate', desktop_validate, args: [desktop_file]) +endif + +appstream_util = find_program('appstream-util', required: false) +if appstream_util.found() + test('appstream-util validate-relax', appstream_util, + args: ['validate-relax', appdata]) +endif + +# A wrapper to start tuhi at the same time as tuhigui, used by the flatpak +configure_file(input: 'tools/tuhi-gui-flatpak.py', + output: 'tuhi-gui-flatpak.py', + copy: true) diff --git a/tuhigui/meson_install.sh b/tuhigui/meson_install.sh new file mode 100644 index 0000000..e5aa42a --- /dev/null +++ b/tuhigui/meson_install.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +if [ -z $DESTDIR ]; then + PREFIX=${MESON_INSTALL_PREFIX:-/usr} + + # Update icon cache + gtk-update-icon-cache -f -t $PREFIX/share/icons/hicolor + + # Install new schemas + #glib-compile-schemas $PREFIX/share/glib-2.0/schemas/ +fi diff --git a/tuhigui/org.freedesktop.TuhiGui.json b/tuhigui/org.freedesktop.TuhiGui.json new file mode 100644 index 0000000..4940392 --- /dev/null +++ b/tuhigui/org.freedesktop.TuhiGui.json @@ -0,0 +1,83 @@ +{ + "app-id": "org.freedesktop.TuhiGui", + "runtime": "org.gnome.Platform", + "runtime-version": "3.30", + "sdk": "org.gnome.Sdk", + "command": "tuhi-gui", + "finish-args": [ + "--share=ipc", + "--socket=x11", + "--talk-name=org.freedesktop.tuhi1", + "--own-name=org.freedesktop.tuhi1", + "--system-talk-name=org.bluez" + ], + "modules": [ + { + "name": "pyxdg", + "buildsystem": "simple", + "sources": [ + { + "type": "git", + "url": "git://anongit.freedesktop.org/xdg/pyxdg" + } + ], + "build-commands": [ + "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." + ] + }, + { + "name": "python-pyparsing", + "buildsystem": "simple", + "sources": [ + { + "type": "archive", + "url": "https://github.com/pyparsing/pyparsing/releases/download/pyparsing_2.4.0/pyparsing-2.4.0.tar.gz", + "sha512": "71877dc006cce5c1b1d45e7cc89cd60e03cb80353387fb0c6498cfc0d69af465dc574d1bceb87248033e7a82694aa940e9fce1ca80b2ef538a8df51f697ef530" + } + ], + "build-commands": [ + "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." + ] + }, + { + "name": "python-svgwrite", + "buildsystem": "simple", + "sources": [ + { + "type": "git", + "url": "https://github.com/mozman/svgwrite.git" + } + ], + "build-commands": [ + "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." + ] + }, + { + "name": "tuhi", + "buildsystem": "simple", + "sources": [ + { + "type": "git", + "url": "https://github.com/tuhiproject/tuhi" + } + ], + "build-commands": [ + "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." + ] + }, + { + "name": "tuhigui", + "buildsystem": "meson", + "sources": [ + { + "type": "git", + "url": "." + } + ], + "post-install": [ + "cp tuhi-gui-flatpak.py /app/bin/tuhi-gui", + "chmod +x /app/bin/tuhi-gui" + ] + } + ] +} diff --git a/tuhigui/po/LINGUAS b/tuhigui/po/LINGUAS new file mode 100644 index 0000000..e0d25c8 --- /dev/null +++ b/tuhigui/po/LINGUAS @@ -0,0 +1 @@ +# Language list must be in alphabetical order diff --git a/tuhigui/po/POTFILES b/tuhigui/po/POTFILES new file mode 100644 index 0000000..8e149a5 --- /dev/null +++ b/tuhigui/po/POTFILES @@ -0,0 +1,17 @@ +data/org.freedesktop.TuhiGui.appdata.xml.in.in +data/org.freedesktop.TuhiGui.desktop.in + +data/ui/AboutDialog.ui.in +data/ui/Drawing.ui +data/ui/DrawingPerspective.ui +data/ui/ErrorPerspective.ui +data/ui/MainWindow.ui +data/ui/SetupPerspective.ui + +tuhigui/application.py +tuhigui/config.py +tuhigui/drawing.py +tuhigui/drawingperspective.py +tuhigui/svg.py +tuhigui/tuhi.py +tuhigui/window.py diff --git a/tuhigui/po/README.md b/tuhigui/po/README.md new file mode 100644 index 0000000..f49bfc0 --- /dev/null +++ b/tuhigui/po/README.md @@ -0,0 +1,37 @@ +i18n +==== + +This directory contains the translations of TuhiGui. + +For errors in translations, please [file an +issue](https://github.com/tuhiproject/tuhi/issues/new). + +New or updated translations are always welcome. To start a new translation, run: + + $ meson translation-build + $ ninja -C translation-build tuhigui-pot + # Now you can optionally remove the build directory + $ rm -rf translation-build + $ cp po/tuhigui.pot po/$lang.po + +where `$lang` is the language code of your target language, e.g. `nl` for Dutch +or `en_GB` for British English. Edit the +[LINGUAS](https://github.com/tuhiproject/tuhigui/blob/master/po/LINGUAS) file and +add your language code, keeping the list sorted alphabetically. Finally, open +the `.po` file you just created and translate all the strings. Don't forget to +fill in the information in the header! + +To update an existing translation, run: + + $ meson translation-build + $ ninja -C translation-build tuhigui-update-po + # Now you can optionally remove the build directory + $ rm -rf translation-build + +and update the `po/$lang.po` file of your target language. + +When you are done translating, file a pull request on +[GitHub](https://github.com/tuhiproject/tuhi) or, if you don't know how to, [open +an issue](https://github.com/tuhiproject/tuhi/issues/new) and attach the `.po` +file there. + diff --git a/tuhigui/po/meson.build b/tuhigui/po/meson.build new file mode 100644 index 0000000..a177335 --- /dev/null +++ b/tuhigui/po/meson.build @@ -0,0 +1 @@ +i18n.gettext('tuhigui', preset: 'glib') diff --git a/tuhigui/tools/tuhi-gui-flatpak.py b/tuhigui/tools/tuhi-gui-flatpak.py new file mode 100755 index 0000000..255ec90 --- /dev/null +++ b/tuhigui/tools/tuhi-gui-flatpak.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +import subprocess +from multiprocessing import Process + +def start_tuhi(): + subprocess.run('tuhi') + +def start_tuhigui(): + subprocess.run('tuhigui') + +if __name__ == '__main__': + tuhi = Process(target=start_tuhi) + tuhi.daemon = True + tuhi.start() + tuhigui = Process(target=start_tuhigui) + tuhigui.start() + tuhigui.join() diff --git a/tuhigui/tuhigui.in b/tuhigui/tuhigui.in new file mode 100755 index 0000000..53fe9bd --- /dev/null +++ b/tuhigui/tuhigui.in @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import gi +import sys +import os + +gi.require_version('Gio', '2.0') +gi.require_version("Gtk", "3.0") +from gi.repository import Gio, Gtk, Gdk + +@devel@ +resource = Gio.resource_load(os.path.join('@pkgdatadir@', 'tuhigui.gresource')) +Gio.Resource._register(resource) + +def install_excepthook(): + old_hook = sys.excepthook + def new_hook(etype, evalue, etb): + old_hook(etype, evalue, etb) + while Gtk.main_level(): + Gtk.main_quit() + sys.exit() + sys.excepthook = new_hook + +def gtk_style(): + css = b""" + .bg-white { + background-color: white; + } + """ + + style_provider = Gtk.CssProvider() + style_provider.load_from_data(css) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + +if __name__ == "__main__": + import gettext + import locale + import signal + from tuhigui.application import Application + + install_excepthook() + gtk_style() + + locale.bindtextdomain('tuhigui', '@localedir@') + locale.textdomain('tuhigui') + gettext.bindtextdomain('tuhigui', '@localedir@') + gettext.textdomain('tuhigui') + + signal.signal(signal.SIGINT, signal.SIG_DFL) + exit_status = Application().run(sys.argv) + sys.exit(exit_status) + diff --git a/tuhigui/tuhigui/__init__.py b/tuhigui/tuhigui/__init__.py new file mode 100644 index 0000000..8ced745 --- /dev/null +++ b/tuhigui/tuhigui/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# diff --git a/tuhigui/tuhigui/application.py b/tuhigui/tuhigui/application.py new file mode 100644 index 0000000..702bdc7 --- /dev/null +++ b/tuhigui/tuhigui/application.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gi.repository import Gio, GLib, Gtk +from .window import MainWindow + +import gi +gi.require_version("Gio", "2.0") +gi.require_version("Gtk", "3.0") + + +class Application(Gtk.Application): + def __init__(self): + super().__init__(application_id='org.freedesktop.TuhiGui', + flags=Gio.ApplicationFlags.FLAGS_NONE) + GLib.set_application_name('TuhiGui') + self._tuhi = None + + def do_startup(self): + Gtk.Application.do_startup(self) + self._build_app_menu() + + def do_activate(self): + window = MainWindow(application=self) + window.present() + + def _build_app_menu(self): + actions = [('about', self._about), + ('quit', self._quit), + ('help', self._help)] + for (name, callback) in actions: + action = Gio.SimpleAction.new(name, None) + action.connect('activate', callback) + self.add_action(action) + + def _about(self, action, param): + builder = Gtk.Builder().new_from_resource('/org/freedesktop/TuhiGui/AboutDialog.ui') + about = builder.get_object('about_dialog') + about.set_transient_for(self.get_active_window()) + about.connect('response', lambda about, param: about.destroy()) + about.show() + + def _quit(self, action, param): + windows = self.get_windows() + for window in windows: + window.destroy() + + def _help(self, action, param): + import time + Gtk.show_uri(None, 'https://github.com/tuhiproject/tuhi/wiki', time.time()) diff --git a/tuhigui/tuhigui/config.py b/tuhigui/tuhigui/config.py new file mode 100644 index 0000000..1a86c7f --- /dev/null +++ b/tuhigui/tuhigui/config.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + + +from gi.repository import GObject + +import xdg.BaseDirectory +import configparser +import logging +import json +from pathlib import Path + +logger = logging.getLogger('config') + +ROOT_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhigui') + + +class Config(GObject.Object): + _config_obj = None + + def __init__(self): + super().__init__() + self.path = Path(ROOT_PATH, 'tuhigui.ini') + self.config = configparser.ConfigParser() + # Don't lowercase options + self.config.optionxform = str + self._drawings = [] + self._load() + self._load_cached_drawings() + + def _load(self): + if not self.path.exists(): + return + + logger.debug(f'configuration found') + self.config.read(self.path) + + def _load_cached_drawings(self): + if not ROOT_PATH.exists(): + return + + for filename in ROOT_PATH.glob('*.json'): + with open(filename) as fd: + self._drawings.append(json.load(fd)) + self.notify('drawings') + + def _write(self): + self.path.resolve().parent.mkdir(parents=True, exist_ok=True) + with open(self.path, 'w') as fd: + self.config.write(fd) + + def _add_key(self, section, key, value): + if section not in self.config: + self.config[section] = {} + self.config[section][key] = value + self._write() + + @GObject.property + def orientation(self): + try: + return self.config['Device']['Orientation'] + except KeyError: + return 'landscape' + + @orientation.setter + def orientation(self, orientation): + assert(orientation in ['landscape', 'portrait']) + self._add_key('Device', 'Orientation', orientation) + + @GObject.property + def drawings(self): + return self._drawings + + def add_drawing(self, timestamp, json_string): + '''Add a drawing JSON with the given timestamp to the backend + storage. This will update self.drawings.''' + ROOT_PATH.mkdir(parents=True, exist_ok=True) + + path = Path(ROOT_PATH, f'{timestamp}.json') + if path.exists(): + return + + # Tuhi may still cache files we've 'deleted' locally. These need to + # be ignored because they're still technically deleted. + deleted = Path(ROOT_PATH, f'{timestamp}.json.deleted') + if deleted.exists(): + return + + with open(path, 'w') as fd: + fd.write(json_string) + + self._drawings.append(json.loads(json_string)) + self.notify('drawings') + + def delete_drawing(self, timestamp): + # We don't delete json files immediately, we just rename them + # so we can resurrect them in the future if need be. + path = Path(ROOT_PATH, f'{timestamp}.json') + target = Path(ROOT_PATH, f'{timestamp}.json.deleted') + path.rename(target) + + self._drawings = [d for d in self._drawings if d['timestamp'] != timestamp] + self.notify('drawings') + + def undelete_drawing(self, timestamp): + path = Path(ROOT_PATH, f'{timestamp}.json') + target = Path(ROOT_PATH, f'{timestamp}.json.deleted') + target.rename(path) + + with open(path) as fd: + self._drawings.append(json.load(fd)) + self.notify('drawings') + + @classmethod + def instance(cls): + if cls._config_obj is None: + cls._config_obj = Config() + return cls._config_obj diff --git a/tuhigui/tuhigui/drawing.py b/tuhigui/tuhigui/drawing.py new file mode 100644 index 0000000..2fbe4b1 --- /dev/null +++ b/tuhigui/tuhigui/drawing.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gettext import gettext as _ +from gi.repository import GObject, Gtk + +from .config import Config +from .svg import JsonSvg + +import datetime +import time +import gi +gi.require_version("Gtk", "3.0") + + +def relative_date(timestamp): + t = datetime.date.fromtimestamp(timestamp) + today = datetime.date.today() + diff = t - today + + if diff.days == 0: + return _('Today') + if diff.days == -1: + return _('Yesterday') + if diff.days > -4: # last 4 days we convert to weekdays + return t.strftime('%A') + + return t.strftime('%x') + + +@Gtk.Template(resource_path='/org/freedesktop/TuhiGui/ui/Drawing.ui') +class Drawing(Gtk.Box): + __gtype_name__ = "Drawing" + + label_timestamp = Gtk.Template.Child() + image_svg = Gtk.Template.Child() + btn_rotate_left = Gtk.Template.Child() + btn_rotate_right = Gtk.Template.Child() + + def __init__(self, json_data, *args, **kwargs): + super().__init__() + self.orientation = Config.instance().orientation + + self.json_data = json_data + self.svg = svg = JsonSvg(json_data, orientation=self.orientation) + day = relative_date(svg.timestamp) + hour = time.strftime('%H:%M', time.localtime(svg.timestamp)) + + self.label_timestamp.set_text(f'{day} {hour}') + self.image_svg.set_from_file(svg.filename) + self.timestamp = svg.timestamp + + def refresh(self): + self.svg = svg = JsonSvg(self.json_data, self.orientation) + self.image_svg.set_from_file(svg.filename) + + @GObject.Property + def name(self): + return "drawing" + + @Gtk.Template.Callback('_on_download_button_clicked') + def _on_download_button_clicked(self, button): + dialog = Gtk.FileChooserDialog(_('Please choose a file'), + None, + Gtk.FileChooserAction.SAVE, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) + + dialog.set_do_overwrite_confirmation(True) + # Translators: the default filename to save to + dialog.set_current_name(_('untitled.svg')) + + filter_any = Gtk.FileFilter() + # Translators: filter name to show all/any files + filter_any.set_name(_('Any files')) + filter_any.add_pattern('*') + filter_svg = Gtk.FileFilter() + # Translators: filter to show svg files only + filter_svg.set_name(_('SVG files')) + filter_svg.add_pattern('*.svg') + dialog.add_filter(filter_svg) + dialog.add_filter(filter_any) + + response = dialog.run() + if response == Gtk.ResponseType.OK: + import shutil + file = dialog.get_filename() + shutil.copyfile(self.svg.filename, file) + # FIXME: error handling + + dialog.destroy() + + @Gtk.Template.Callback('_on_delete_button_clicked') + def _on_delete_button_clicked(self, button): + Config.instance().delete_drawing(self.timestamp) + + @Gtk.Template.Callback('_on_rotate_button_clicked') + def _on_rotate_button_clicked(self, button): + if button == self.btn_rotate_left: + advance = 1 + else: + advance = 3 + + orientations = ['portrait', 'landscape', 'reverse-portrait', 'reverse-landscape'] * 3 + o = orientations[orientations.index(self.orientation) + advance] + self.orientation = o + self.refresh() diff --git a/tuhigui/tuhigui/drawingperspective.py b/tuhigui/tuhigui/drawingperspective.py new file mode 100644 index 0000000..bf9d923 --- /dev/null +++ b/tuhigui/tuhigui/drawingperspective.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gettext import gettext as _ +from gi.repository import GObject, Gtk +from .drawing import Drawing +from .config import Config + +import time +import gi +import logging + +gi.require_version("Gtk", "3.0") + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger('drawingperspective') + + +def relative_time(seconds): + MIN = 60 + H = 60 * MIN + DAY = 24 * H + WEEK = 7 * DAY + + if seconds < 30: + return _('just now') + if seconds < 5 * MIN: + return _('a few minutes ago') + if seconds < H: + minutes = int(seconds / MIN / 10) * 10 + return _(f'{minutes} minutes ago') + if seconds < DAY: + hours = int(seconds / H) + return _(f'{hours} hours ago') + if seconds < 4 * WEEK: + days = int(seconds / DAY) + return _(f'{days} days ago') + if seconds > 10 * 365 * DAY: + return _('not yet') + + return _('a long time ago') + + +@Gtk.Template(resource_path="/org/freedesktop/TuhiGui/ui/DrawingPerspective.ui") +class DrawingPerspective(Gtk.Stack): + __gtype_name__ = "DrawingPerspective" + + image_battery = Gtk.Template.Child() + flowbox_drawings = Gtk.Template.Child() + spinner_sync = Gtk.Template.Child() + label_last_sync = Gtk.Template.Child() + overlay_undo = Gtk.Template.Child() + notification_delete_undo = Gtk.Template.Child() + notification_delete_close = Gtk.Template.Child() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.known_drawings = [] + self.last_sync_time = 0 + self._sync_label_timer = GObject.timeout_add_seconds(60, self._update_sync_label) + self._update_sync_label() + Config.instance().connect('notify::orientation', self._on_orientation_changed) + + def _on_orientation_changed(self, config, pspec): + # When the orientation changes, we just re-generate all SVGs. This + # isn't something that should happen very often anyway so meh. + self.flowbox_drawings.foreach(lambda child: child.get_child().refresh()) + + def _cache_drawings(self, device, pspec): + # The config backend filters duplicates anyway, so don't care here + for ts in self.device.drawings_available: + json_string = self.device.json(ts) + Config.instance().add_drawing(ts, json_string) + + def _update_drawings(self, config, pspec): + for js in config.drawings: + if js in self.known_drawings: + continue + + self.known_drawings.append(js) + + drawing = Drawing(js) + + # We don't know which order we get drawings from the device, so + # let's do a sorted insert here + index = 0 + child = self.flowbox_drawings.get_child_at_index(index) + while child is not None: + if child.get_child().timestamp < drawing.timestamp: + break + index += 1 + child = self.flowbox_drawings.get_child_at_index(index) + + self.flowbox_drawings.insert(drawing, index) + + # Remove deleted ones + deleted = [d for d in self.known_drawings if d not in config.drawings] + for d in deleted: + def delete_matching_child(child, drawing): + if child.get_child().timestamp == drawing['timestamp']: + self.flowbox_drawings.remove(child) + self.known_drawings.remove(drawing) + self.notification_delete_undo.deleted_drawing = drawing['timestamp'] + self.overlay_undo.set_reveal_child(True) + self.flowbox_drawings.foreach(delete_matching_child, d) + + @GObject.Property + def device(self): + return self._device + + @device.setter + def device(self, device): + self._device = device + + device.connect('notify::connected', self._on_connected) + device.connect('notify::listening', self._on_listening_stopped) + device.connect('notify::sync-state', self._on_sync_state) + device.connect('notify::battery-percent', self._on_battery_changed) + device.connect('notify::battery-state', self._on_battery_changed) + + # This is a bit convoluted. We need to cache all drawings + # because Tuhi doesn't have guaranteed storage. So any json that + # comes in from Tuhi, we pass to our config backend to save + # somewhere. + # The config backend adds the json file and emits a notify for the + # json itself (once cached) that we then actually use for SVG + # generation. + device.connect('notify::drawings-available', self._cache_drawings) + Config.instance().connect('notify::drawings', self._update_drawings) + + self._on_battery_changed(device, None) + + self._update_drawings(Config.instance(), None) + + # We always want to sync on startup + logger.debug(f'{device.name} - starting to listen') + device.start_listening() + + @GObject.Property + def name(self): + return "drawing_perspective" + + def _on_battery_changed(self, device, pspec): + if device.battery_percent > 80: + fill = 'full' + elif device.battery_percent > 40: + fill = 'good' + elif device.battery_percent > 10: + fill = 'low' + else: + fill = 'caution' + + if device.battery_state == 1: + state = '-charging' + elif device.battery_state == 0: # unknown + fill = 'missing' + state = '' + else: + state = '' + batt_icon_name = f'battery-{fill}{state}-symbolic' + _, isize = self.image_battery.get_icon_name() + self.image_battery.set_from_icon_name(batt_icon_name, isize) + self.image_battery.set_tooltip_text(f'{device.battery_percent}%') + + def _on_sync_state(self, device, pspec): + if device.sync_state: + self.spinner_sync.start() + else: + self.spinner_sync.stop() + self.last_sync_time = time.time() + self._update_sync_label() + + def _update_sync_label(self): + now = time.time() + self.label_last_sync.set_text(f'{relative_time(now - self.last_sync_time)}') + return True + + def _on_connected(self, device, pspec): + # Turns out we don't really care about whether the device is + # connected or not, it has little effect on how we work here + pass + + def _on_listening_stopped(self, device, pspec): + if not device.listening: + logger.debug(f'{device.name} - listening stopped, restarting') + # We never want to stop listening + device.start_listening() + + @Gtk.Template.Callback('_on_undo_close_clicked') + def _on_undo_close_clicked(self, button): + self.overlay_undo.set_reveal_child(False) + + @Gtk.Template.Callback('_on_undo_clicked') + def _on_undo_clicked(self, button): + Config.instance().undelete_drawing(button.deleted_drawing) + self.overlay_undo.set_reveal_child(False) diff --git a/tuhigui/tuhigui/svg.py b/tuhigui/tuhigui/svg.py new file mode 100644 index 0000000..f59c425 --- /dev/null +++ b/tuhigui/tuhigui/svg.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gi.repository import GObject + +import xdg.BaseDirectory +import svgwrite +import os + +DATA_PATH = os.path.join(xdg.BaseDirectory.xdg_data_home, 'tuhigui') + + +class JsonSvg(GObject.Object): + def __init__(self, json, orientation, *args, **kwargs): + self.json = json + try: + os.mkdir(DATA_PATH) + except FileExistsError: + pass + + self.timestamp = json['timestamp'] + self.filename = os.path.join(DATA_PATH, f'{self.timestamp}.svg') + self.orientation = orientation + self._convert() + + def _convert(self): + js = self.json + dimensions = js['dimensions'] + if dimensions == [0, 0]: + width, height = 100, 100 + else: + # Original dimensions are too big for SVG Standard + # so we normalize them + width, height = dimensions[0] / 100, dimensions[1] / 100 + + if self.orientation in ['portrait', 'reverse-Portrait']: + size = (height, width) + else: + size = (width, height) + svg = svgwrite.Drawing(filename=self.filename, size=size) + g = svgwrite.container.Group(id='layer0') + for stroke_num, s in enumerate(js['strokes']): + + points_with_sk_width = [] + + for p in s['points']: + + x, y = p['position'] + # Normalize coordinates too + x, y = x / 100, y / 100 + + if self.orientation == 'reverse-portrait': + x, y = y, width - x + elif self.orientation == 'portrait': + x, y = height - y, x + elif self.orientation == 'reverse-landscape': + x, y = width - x, height - y + + delta = (p['pressure'] - 1000.0) / 1000.0 + stroke_width = 0.4 + 0.20 * delta + points_with_sk_width.append((x, y, stroke_width)) + + lines = svgwrite.container.Group(id=f'strokes_{stroke_num}', stroke='black') + for i, (x, y, stroke_width) in enumerate(points_with_sk_width): + if i != 0: + xp, yp, stroke_width_p = points_with_sk_width[i - 1] + lines.add( + svg.line( + start=(xp, yp), + end=(x, y), + stroke_width=stroke_width, + style='fill:none' + ) + ) + g.add(lines) + + svg.add(g) + svg.save() diff --git a/tuhigui/tuhigui/tuhi.py b/tuhigui/tuhigui/tuhi.py new file mode 100644 index 0000000..64c3702 --- /dev/null +++ b/tuhigui/tuhigui/tuhi.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gi.repository import GObject, Gio, GLib +import argparse +import errno +import os +import logging +import re +import xdg.BaseDirectory + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger('tuhi') + +CONFIG_PATH = os.path.join(xdg.BaseDirectory.xdg_data_home, 'tuhi-kete') + +TUHI_DBUS_NAME = 'org.freedesktop.tuhi1' +ORG_FREEDESKTOP_TUHI1_MANAGER = 'org.freedesktop.tuhi1.Manager' +ORG_FREEDESKTOP_TUHI1_DEVICE = 'org.freedesktop.tuhi1.Device' +ROOT_PATH = '/org/freedesktop/tuhi1' + +ORG_BLUEZ_DEVICE1 = 'org.bluez.Device1' + + +class DBusError(Exception): + def __init__(self, message): + self.message = message + + +class _DBusObject(GObject.Object): + _connection = None + + def __init__(self, name, interface, objpath): + GObject.GObject.__init__(self) + + # this is not handled asynchronously because if we fail to + # get the session bus, we have other issues + if _DBusObject._connection is None: + self._connect_to_session() + + self.interface = interface + self.objpath = objpath + self._online = False + self._name = name + try: + self._connect() + except DBusError: + self._reconnect_timer = GObject.timeout_add_seconds(2, self._on_reconnect_timer) + + def _connect(self): + try: + self.proxy = Gio.DBusProxy.new_sync(self._connection, + Gio.DBusProxyFlags.NONE, None, + self._name, self.objpath, + self.interface, None) + if self.proxy.get_name_owner() is None: + raise DBusError(f'No-one is handling {self._name}, is the daemon running?') + + self._online = True + self.notify('online') + except GLib.Error as e: + if (e.domain == 'g-io-error-quark' and + e.code == Gio.IOErrorEnum.DBUS_ERROR): + raise DBusError(e.message) + else: + raise e + + self.proxy.connect('g-properties-changed', self._on_properties_changed) + self.proxy.connect('g-signal', self._on_signal_received) + + def _on_reconnect_timer(self): + try: + logger.debug('reconnecting') + self._connect() + return False + except DBusError: + return True + + @GObject.Property + def online(self): + return self._online + + def _connect_to_session(self): + try: + _DBusObject._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except GLib.Error as e: + if (e.domain == 'g-io-error-quark' and + e.code == Gio.IOErrorEnum.DBUS_ERROR): + raise DBusError(e.message) + else: + raise e + + def _on_properties_changed(self, proxy, changed_props, invalidated_props): + # Implement this in derived classes to respond to property changes + pass + + def _on_signal_received(self, proxy, sender, signal, parameters): + # Implement this in derived classes to respond to signals + pass + + def property(self, name): + p = self.proxy.get_cached_property(name) + if p is not None: + return p.unpack() + return p + + def terminate(self): + del(self.proxy) + + +class _DBusSystemObject(_DBusObject): + ''' + Same as the _DBusObject, but connects to the system bus instead + ''' + def __init__(self, name, interface, objpath): + self._connect_to_system() + super().__init__(name, interface, objpath) + + def _connect_to_system(self): + try: + self._connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + except GLib.Error as e: + if (e.domain == 'g-io-error-quark' and + e.code == Gio.IOErrorEnum.DBUS_ERROR): + raise DBusError(e.message) + else: + raise e + + +class BlueZDevice(_DBusSystemObject): + def __init__(self, objpath): + super().__init__('org.bluez', ORG_BLUEZ_DEVICE1, objpath) + self.proxy.connect('g-properties-changed', self._on_properties_changed) + + @GObject.Property + def connected(self): + return self.proxy.get_cached_property('Connected').unpack() + + def _on_properties_changed(self, obj, properties, invalidated_properties): + properties = properties.unpack() + + if 'Connected' in properties: + self.notify('connected') + + +class TuhiKeteDevice(_DBusObject): + __gsignals__ = { + 'button-press-required': + (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + 'registered': + (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + } + + def __init__(self, manager, objpath): + _DBusObject.__init__(self, TUHI_DBUS_NAME, + ORG_FREEDESKTOP_TUHI1_DEVICE, + objpath) + self.manager = manager + self.is_registering = False + self._bluez_device = BlueZDevice(self.property('BlueZDevice')) + self._bluez_device.connect('notify::connected', self._on_connected) + self._sync_state = 0 + + @classmethod + def is_device_address(cls, string): + if re.match(r'[0-9a-f]{2}(:[0-9a-f]{2}){5}$', string.lower()): + return string + raise argparse.ArgumentTypeError(f'"{string}" is not a valid device address') + + @GObject.Property + def address(self): + return self._bluez_device.property('Address') + + @GObject.Property + def name(self): + return self._bluez_device.property('Name') + + @GObject.Property + def dimensions(self): + return self.property('Dimensions') + + @GObject.Property + def listening(self): + return self.property('Listening') + + @GObject.Property + def drawings_available(self): + return self.property('DrawingsAvailable') + + @GObject.Property + def battery_percent(self): + return self.property('BatteryPercent') + + @GObject.Property + def battery_state(self): + return self.property('BatteryState') + + @GObject.Property + def connected(self): + return self._bluez_device.connected + + @GObject.Property + def sync_state(self): + return self._sync_state + + def _on_connected(self, bluez_device, pspec): + self.notify('connected') + + def register(self): + logger.debug(f'{self}: Register') + # FIXME: Register() doesn't return anything useful yet, so we wait until + # the device is in the Manager's Devices property + self.s1 = self.manager.connect('notify::devices', self._on_mgr_devices_updated) + self.is_registering = True + self.proxy.Register() + + def start_listening(self): + self.proxy.StartListening() + + def stop_listening(self): + try: + self.proxy.StopListening() + except GLib.Error as e: + if (e.domain != 'g-dbus-error-quark' or + e.code != Gio.IOErrorEnum.EXISTS or + Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'): + raise e + + def json(self, timestamp): + SUPPORTED_FILE_FORMAT = 1 + return self.proxy.GetJSONData('(ut)', SUPPORTED_FILE_FORMAT, timestamp) + + def _on_signal_received(self, proxy, sender, signal, parameters): + if signal == 'ButtonPressRequired': + logger.info(f'{self}: Press button on device now') + self.emit('button-press-required', self) + elif signal == 'ListeningStopped': + err = parameters[0] + if err == -errno.EACCES: + logger.error(f'{self}: wrong device, please re-register.') + elif err < 0: + logger.error(f'{self}: an error occured: {os.strerror(-err)}') + self.notify('listening') + elif signal == 'SyncState': + self._sync_state = parameters[0] + self.notify('sync-state') + + def _on_properties_changed(self, proxy, changed_props, invalidated_props): + if changed_props is None: + return + + changed_props = changed_props.unpack() + + if 'DrawingsAvailable' in changed_props: + self.notify('drawings-available') + elif 'Listening' in changed_props: + self.notify('listening') + elif 'BatteryPercent' in changed_props: + self.notify('battery-percent') + elif 'BatteryState' in changed_props: + self.notify('battery-state') + + def __repr__(self): + return f'{self.address} - {self.name}' + + def _on_mgr_devices_updated(self, manager, pspec): + if not self.is_registering: + return + + for d in manager.devices: + if d.address == self.address: + self.is_registering = False + self.manager.disconnect(self.s1) + del(self.s1) + logger.info(f'{self}: Registration successful') + self.emit('registered', self) + + def terminate(self): + try: + self.manager.disconnect(self.s1) + except AttributeError: + pass + self._bluez_device.terminate() + super(TuhiKeteDevice, self).terminate() + + +class TuhiKeteManager(_DBusObject): + __gsignals__ = { + 'unregistered-device': + (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + } + + def __init__(self): + _DBusObject.__init__(self, TUHI_DBUS_NAME, + ORG_FREEDESKTOP_TUHI1_MANAGER, + ROOT_PATH) + + self._devices = {} + self._unregistered_devices = {} + logger.info('starting up') + + if not self.online: + self.connect('notify::online', self._init) + else: + self._init() + + def _init(self, *args, **kwargs): + logger.info('manager is online') + for objpath in self.property('Devices'): + device = TuhiKeteDevice(self, objpath) + self._devices[device.address] = device + + @GObject.Property + def devices(self): + return [v for k, v in self._devices.items()] + + @GObject.Property + def unregistered_devices(self): + return [v for k, v in self._unregistered_devices.items()] + + @GObject.Property + def searching(self): + return self.proxy.get_cached_property('Searching') + + def start_search(self): + self._unregistered_devices = {} + self.proxy.StartSearch() + + def stop_search(self): + try: + self.proxy.StopSearch() + except GLib.Error as e: + if (e.domain != 'g-dbus-error-quark' or + e.code != Gio.IOErrorEnum.EXISTS or + Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'): + raise e + self._unregistered_devices = {} + + def terminate(self): + for dev in self._devices.values(): + dev.terminate() + self._devices = {} + self._unregistered_devices = {} + super(TuhiKeteManager, self).terminate() + + def _on_properties_changed(self, proxy, changed_props, invalidated_props): + if changed_props is None: + return + + changed_props = changed_props.unpack() + + if 'Devices' in changed_props: + objpaths = changed_props['Devices'] + for objpath in objpaths: + try: + d = self._unregistered_devices[objpath] + self._devices[d.address] = d + del self._unregistered_devices[objpath] + except KeyError: + # if we called Register() on an existing device it's not + # in unregistered devices + pass + self.notify('devices') + if 'Searching' in changed_props: + self.notify('searching') + + def _handle_unregistered_device(self, objpath): + for addr, dev in self._devices.items(): + if dev.objpath == objpath: + self.emit('unregistered-device', dev) + return + + device = TuhiKeteDevice(self, objpath) + self._unregistered_devices[objpath] = device + + logger.debug(f'New unregistered device: {device}') + self.emit('unregistered-device', device) + + def _on_signal_received(self, proxy, sender, signal, parameters): + if signal == 'SearchStopped': + self.notify('searching') + elif signal == 'UnregisteredDevice': + objpath = parameters[0] + self._handle_unregistered_device(objpath) + + def __getitem__(self, btaddr): + return self._devices[btaddr] diff --git a/tuhigui/tuhigui/window.py b/tuhigui/tuhigui/window.py new file mode 100644 index 0000000..475b418 --- /dev/null +++ b/tuhigui/tuhigui/window.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# + +from gettext import gettext as _ +from gi.repository import Gtk, Gio, GLib, GObject + +from .drawingperspective import DrawingPerspective +from .tuhi import TuhiKeteManager +from .config import Config + +import logging +import gi +gi.require_version("Gtk", "3.0") + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger('window') + +MENU_XML = """ + + + +
+ + Portrait + win.orientation + portrait + + + Landscape + win.orientation + landscape + +
+
+ + Help + app.help + + + About + app.about + +
+
+
+""" + + +@Gtk.Template(resource_path="/org/freedesktop/TuhiGui/ui/ErrorPerspective.ui") +class ErrorPerspective(Gtk.Box): + ''' + The page loaded when we cannot connect to the Tuhi DBus server. + ''' + __gtype_name__ = "ErrorPerspective" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @GObject.Property + def name(self): + return "error_perspective" + + +@Gtk.Template(resource_path="/org/freedesktop/TuhiGui/ui/SetupPerspective.ui") +class SetupDialog(Gtk.Dialog): + ''' + The setup dialog when we don't yet have a registered device with Tuhi. + ''' + __gtype_name__ = "SetupDialog" + __gsignals__ = { + 'new-device': + (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + } + + stack = Gtk.Template.Child() + label_devicename_p1 = Gtk.Template.Child() + btn_quit = Gtk.Template.Child() + + def __init__(self, tuhi, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tuhi = tuhi + self._sig = tuhi.connect('unregistered-device', self._on_unregistered_device) + tuhi.start_search() + self.device = None + + def _on_unregistered_device(self, tuhi, device): + tuhi.disconnect(self._sig) + + self.label_devicename_p1.set_text(_(f'Connecting to {device.name}')) + self.stack.set_visible_child_name('page1') + self._sig = device.connect('button-press-required', self._on_button_press_required) + device.register() + + def _on_button_press_required(self, tuhi, device): + tuhi.disconnect(self._sig) + + self.stack.set_visible_child_name('page2') + self._sig = device.connect('registered', self._on_registered) + + def _on_registered(self, tuhi, device): + tuhi.disconnect(self._sig) + self.device = device + self.response(Gtk.ResponseType.OK) + + @GObject.Property + def name(self): + return "setup_dialog" + + +@Gtk.Template(resource_path='/org/freedesktop/TuhiGui/ui/MainWindow.ui') +class MainWindow(Gtk.ApplicationWindow): + __gtype_name__ = 'MainWindow' + + stack_perspectives = Gtk.Template.Child() + headerbar = Gtk.Template.Child() + menubutton1 = Gtk.Template.Child() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._tuhi = TuhiKeteManager() + + action = Gio.SimpleAction.new_stateful('orientation', GLib.VariantType('s'), + GLib.Variant('s', 'landscape')) + action.connect('activate', self._on_orientation_changed) + action.set_state(GLib.Variant.new_string(Config.instance().orientation)) + self.add_action(action) + + builder = Gtk.Builder.new_from_string(MENU_XML, -1) + menu = builder.get_object("primary-menu") + self.menubutton1.set_menu_model(menu) + + ep = ErrorPerspective() + self._add_perspective(ep) + self.stack_perspectives.set_visible_child_name(ep.name) + + # the dbus bindings need more async... + if not self._tuhi.online: + self._tuhi.connect('notify::online', self._on_dbus_online) + else: + self._on_dbus_online() + + def _on_dbus_online(self, *args, **kwargs): + logger.debug('dbus is online') + + dp = DrawingPerspective() + self._add_perspective(dp) + active = dp + self.headerbar.set_title(f'Tuhi') + self.stack_perspectives.set_visible_child_name(active.name) + + if not self._tuhi.devices: + dialog = SetupDialog(self._tuhi) + dialog.set_transient_for(self) + dialog.connect('response', self._on_setup_dialog_closed) + dialog.show() + else: + dp.device = self._tuhi.devices[0] + active = dp + self.headerbar.set_title(f'Tuhi - {dp.device.name}') + + def _on_setup_dialog_closed(self, dialog, response): + device = dialog.device + dialog.destroy() + + if response != Gtk.ResponseType.OK or device is None: + self.destroy() + return + + logger.debug('device was registered') + self.headerbar.set_title(f'Tuhi - {device.name}') + + dp = self._get_child('drawing_perspective') + dp.device = device + self.stack_perspectives.set_visible_child_name(dp.name) + + def _add_perspective(self, perspective): + self.stack_perspectives.add_named(perspective, perspective.name) + + def _get_child(self, name): + return self.stack_perspectives.get_child_by_name(name) + + def _on_reconnect_tuhi(self, tuhi): + self._tuhi = tuhi + + def _on_orientation_changed(self, action, label): + action.set_state(label) + Config.instance().orientation = label.get_string() # this is a GVariant