Merge pull request #145 from whot/wip/tuhigui

Merge the TuhiGui into this repo
pull/154/head
Peter Hutterer 2019-08-09 15:28:29 +10:00 committed by GitHub
commit 3e44e28707
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 3436 additions and 524 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@ flatpak*
tuhi.egg-info
__pycache__
*.swp
*.svg

398
DBusInterface.md Normal file
View File

@ -0,0 +1,398 @@
DBus Interface
--------------
Tuhi has two main components. A DBus session daemon that handles
communication with the device and a GTK application that provides the
graphical user interface.
The DBus session daemon uses the following interface:
```
org.freedesktop.tuhi1.Manager
Property: Devices (ao)
Array of object paths to known (previously registered, but not necessarily
connected) devices. Note that a "registered" device is one that has been
initialized via the Wacom SmartPad custom protocol. A device does not
need to be paired over Bluetooth to register.
Property: Searching (b)
Indicates whether the daemon is currently searching for devices.
This property is set to True when a StartSearching() request initiates
the search for device connections. When the StartSearching() request
completes upon timeout, or when StopSearching() is called, the property
is set to False.
When a pariable device is found, the UnregisteredDevice signal is sent to
the caller that initiated the search process.
Read-only
Property: JSONDataVersions (au)
Specifies the JSON file format versions the server supports. The
client must request one of these versions in Device.GetJSONData().
Read-only, constant
Method: StartSearch() -> ()
Start searching for available devices ready for registering
for an unspecified timeout. When the timeout expires or an error
occurs, a SearchStopped signal is sent indicating success or error.
If a client that successfully initated a listening process calls
StartSearching() again, that call is ignored and no signal is
generated for that call.
Method: StopSearch() -> ()
Stop listening to available devices ready for registering. If called after
StartSearch() and before a SearchStopped signal has been received,
this method triggers the SearchStopped signal. That signal indicates
success or an error.
If this method is called before StartSearch() or after the
SearchStopped signal, it is ignored and no signal is generated.
Note that between calling StopSearch() and the SearchStopped signal
arriving, UnregisteredDevice signals may still arrive.
Signal: UnregisteredDevice(o)
Indicates that a device can be registered. This signal may be
sent after a StartSearch() call and before SearchStopped(). This
signal is sent once per available device and only to the client that
initiated the search process with StartSearch.
When this signal is sent, a org.freedesktop.tuhi1.Device object was
created, the object path is the argument to this signal.
A client must immediately call Register() on that object if
registering with that object is desired. See the documentation for
that interface for details.
When the search timeout expires, the device may be removed by the
daemon again. Note that until the device is registered, the device is not
listed in the managers Devices property.
Signal: SearchStopped(i)
Sent when the search has stopped. An argument of 0 indicates a
successful termination of the search process, either when a device
has been registered or the timeout expired.
If the errno is -EAGAIN, the daemon is already searching for devices
on behalf of another client. In this case, this client should wait for
the Searching property to change and StartSearching() once the
property is set to False.
Once this signal has been sent, all devices announced through
UnregisteredDevice signals should be considered invalidated. Attempting to
Register() one of the devices after the SearchStopped() signal may result
in an error.
In case of error, the argument is a negative errno.
org.freedesktop.tuhi1.Device
Interface to a device known by tuhi. Each object in Manager.Devices
implements this interface.
Property: BlueZDevice (o)
Object path to the org.bluez.Device1 device that is this device.
Read-only
Property: Dimensions (uu)
The physical dimensions (width, height) in µm
Read-only
Property: BatteryPercent (u)
The last known battery charge level in percent. This charge level is
only accurate when the BatteryState is other than Unknown.
When the BatteryState is Unknown and BatteryPercent is nonzero, the
value is the last known percentage value.
Read-only
Property: BatteryState (u)
An enum describing the battery state. Permitted enum values are
0: Unknown
1: Charging
2: Discharging
'Unknown' may refer to a state that could not be read, a state
that has not yet been updated, or a state that has not updated within
a daemon-internal time period. Thus, a device that is connected but
does not regularly send battery updates may eventually switch to
'Unknown'.
Read-only
Property: DrawingsAvailable (at)
An array of timestamps of the available drawings. The timestamp of
each drawing can be used as argument to GetJSONData(). Timestamps are
in seconds since the Epoch and may be used to display information to
the user or sort data.
Read-only
Property: Listening (b)
Indicates whether the daemon is currently listening for the device.
This property is set to True when a StartListening() request initiates
the search for device connections. When the StartListening() request
completes upon timeout, or when StopListening() is called, the property
is set to False.
When the user press the button on the device, the daemon connects
to the device, downloads all drawings from the device and disconnects
from the device.
If successfull, the drawings are deleted from the device. The data is
held by the daemon in non-persistent storage until the daemon is stopped
or we run out of memory, whichever happens earlier.
Use GetJSONData() to retrieve the data from the daemon.
DO NOT RELY ON THE DAEMON FOR PERMANENT STORAGE
When drawings become available from the device, the DrawingsAvailable
property updates to the number of available drawings.
When the button is pressed multiple times, any new data is appended
to the existing list of drawings as long as this property is True.
Read-only
Property: Live(b)
Indicates whether the device is currently in Live mode. When in live
mode, the device does not store drawings internally for a later sync
but instead fowards the events immediately, similar to a traditional
graphics tablet. See StartLive() for more details.
Read-only
Method: Register() -> (i)
Register the device. If the device is already registered, calls to
this method immediately return success.
Otherwise, the device is registered and this function returns success (0)
or a negative errno on failure.
Method: StartListening() -> ()
Listen for data from this device and connect to the device when it
becomes available. The daemon listens to the device until the client
calls StopListening() or the client disconnects, whichever happens
earlier.
The ListeningStopped signal is sent when the listening terminates,
either on success or with an error. A client should handle this signal
to be notified of any errors.
When the daemon starts listening, the Listening property is updated
accordingly.
If a client that successfully initated a listening process calls
StartListening() again, that call is ignored and no signal is
generated for that call.
Method: StopListening() -> ()
Stop listening for data on this device. If called after
StartListening(), this method triggers the ListenStopped signal.
That signal indicates success or an error.
If this method is called before StartListening() or after the
ListeningStopped signal, it is ignored and no signal is generated.
Note that between calling StopListening() and the ListeningStopped
signal arriving, the property DrawingsAvailable may still be updated
and it's the responsibility of the client to fetch the JSON data.
Method: StartLive(fd: h) -> (i)
Starts live mode on this device. This disables offline storage of
drawing data on the device and instead switches the device to a mode
where it immediately reports the pen data, similar to a traditional
graphics tablet.
The LiveStopped signal is sent when live mode terminates, either on
success or with an error. A client should handle this signal to be
notified of any errors.
When live mode enables, the Live property is updated accordingly.
If a client that successfully initated a listening process calls
StartListening() again, that call is ignored and no signal is
generated for that call.
The fd argument is a file descriptor that will be used to forward
events to. The format is the one used by the Linux kernel's UHID
device, see linux/uhid.h for details.
Method: StopLive() - >()
Stop live mode on this device. If called after StartLive(), this
method triggers the LiveStopped signal. That signal indicates
success or an error.
If this method is called before StartLive() or after the LiveStopped
signal, it is ignored and no signal is generated.
Note that between calling StopLive() and the LiveStopped signal
arriving, the device may still send events. It's the responsibility of
the client to handle events until the LiveStopped signal arrives.
Method: GetJSONData(file-version: u, timestamp: t) -> (s)
Returns a JSON file with the drawings specified by the timestamp
argument. The requested timestamp must be one of the entries in the
DrawingsAvailable property value. The file-version argument specifies
the file format version the client requests. See section JSON FILE
FORMAT for the format of the returned data.
Returns a string representing the JSON data from the last drawings or
the empty string if the timestamp is not available or the file format
version is outside the server-supported range advertised in
Manager.JSONDataVersions.
Signal: ButtonPressRequired()
Sent when the user is expected to press the physical button on the
device. A client should display a notification in response, if the
user does not press the button during the (firmware-specific) timeout
the current operation will fail.
Signal: ListeningStopped(i)
Sent when the listen process has stopped. An argument of 0 indicates a
successful termination, i.e. in response to the client calling
StopListening(). Otherwise, the argument is a negative errno
indicating the type of error.
If the errno is -EAGAIN, the daemon is already listening to the device
on behalf of another client. In this case, this client should wait for
the Listening property to change and StartListening() once the
property is set to False.
If the error is -EBADE, the device is not ready for registering/in
listening mode and registration/listening was requested. In
this case, the client should indicate to the user that the device
needs to be registered first or switched to listening mode.
If the error is -EACCES, the device is not registered with the daemon
or incorrectly registered. This may happen when the device was
registered with another host since the last connection.
The following other errnos may be sent by the daemon:
-EPROTO: the daemon has encountered a protocol error with the device.
-ETIME: timeout while communicating with the device.
These errnos indicate a bug in the daemon, and the client should
display a message to that effect.
Signal: LiveStopped(i)
Sent when live mode is stopped. An argument of 0 indicates a
successful termination, i.e. in response to the client calling
StopLive(). Otherwise, the argument is a negative errno
indicating the type of error.
If the errno is -EAGAIN, the daemon has already enabled live mode on
device on behalf of another client. In this case, this client should
wait for the Live property to change and StartLive() once the property
is set to False.
If the error is -EBADE, the device is not ready for live mode, most
likely because it is in registration mode. In this case, the client
should indicate to the user that the device needs to be registered
first.
If the error is -EACCES, the device is not registered with the daemon
or incorrectly registered. This may happen when the device was
registered with another host since the last connection.
The following other errnos may be sent by the daemon:
-EPROTO: the daemon has encountered a protocol error with the device.
-ETIME: timeout while communicating with the device.
These errnos indicate a bug in the daemon, and the client should
display a message to that effect.
Signal: SyncState(i)
An enum to represent the current synchronization state of the device.
When on (1), Tuhi is currently trying to download data from the
device. When off (0), Tuhi is not currently connecting to the device.
This signal should be used for UI feedback.
This signal is only send when the device is **not** in Live mode.
```
JSON File Format
----------------
The current file format version is 1. A server may only support a subset of
historical file formats, this subset is advertized as list of versions in
the **org.freedesktop.tuhi1.Manager.JSONDataVersions** property. Likewise, a
client may only support a subset of the possible formats. A client should
always pick the highest format supported by both the client and the server.
Below is the example file format (with comments, not present in the real
files). The JSON objects are "drawing" (the root object), "strokes",
"points". Pseudo-code is used to illustrate the objects in the file.
```
class Drawing {
version: uint32
devicename: string
dimensions: [uint32, uint32] // x/y physical dimensions in µm
timestamp: uint64
strokes: [ Stroke, Stroke, ...]
}
```
The **strokes** list contains all strokes of a single drawing, each stroke
consisting of a number of **points**.
```
class Stroke {
points: [Point, Point, ...]
}
```
The **points** list contains the actual pen data.
```
class Point {
toffset: uint32
position: [uint32, uint32]
pressure: uint32
}
```
An expanded file looks like this:
```
{
"version" : 1, // JSON file format version number
"devicename": "Wacom Bamboo Spark",
"dimensions": [ 100000, 200000], // width/height in µm
"timestamp" : 12345,
"strokes" : [
{
"points": [
// all items in a point are optional. Unknown dictionary
// entries must be ignored as future devices may add
// new axes.
{ "toffset" : 12366, "position" : [ 100, 200 ], "pressure" : 1000 },
{ "toffset" : 12368, "pressure" : 800 },
{ "toffset" : 12366, "position" : [ 120, 202 ] },
]
},
{ "points" : ... }
]
}
```
Units used by this interface
----------------------------
* Physical distances for x/y axes are in µm from the sensor's top-left
position. (Note that on the Spark and on the Slate at least, the sensor
is turned 90 degrees clockwise, so (0,0) is at the 'natural' top-right
corner)
* Stylus pressure is normalized to a range of [0, 0xffff], inclusive.
* Timestamps are in seconds in unix epoch, time offsets are in ms after the
most recent timestamp.

469
README.md
View File

@ -1,10 +1,9 @@
TUHI
Tuhi
=====
Tuhi is a DBus session daemon that connects to and fetches the data from the
Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). The data is
provided to clients in the form of JSON, any conversion to other formats
like SVG must be done by the clients.
Tuhi is a GTK application that connects to and fetches the data from the
Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). Users can save the
data as SVGs.
Tuhi is the Māori word for "to draw".
@ -17,6 +16,61 @@ Devices tested and known to be supported:
* Bamboo Slate
* Intuos Pro Paper
Building Tuhi
-------------
To build and run Tuhi from the repository directly:
```
$> git clone http://github.com/tuhiproject/tuhi
$> cd tuhi
$> meson builddir
$> ninja -C builddir
$> ./builddir/tuhi.devel
```
Tuhi requires Python v3.6 or above.
Install Tuhi
---------------
To install and run Tuhi:
```
$> git clone http://github.com/tuhiproject/tuhi
$> cd tuhi
$> meson builddir
$> ninja -C builddir install
```
Run Tuhi with:
```
$> tuhi
```
Tuhi requires Python v3.6 or above.
Flatpak
-------
```
$> git clone http://github.com/tuhiproject/tuhi
$> cd tuhi
$> flatpak-builder flatpak_builddir org.freedesktop.Tuhi.json --install --user --force-clean
$> flatpak run org.freedesktop.Tuhi
```
Note that Flatpak's containers use different XDG directories. This affects
Tuhi being able to remember devices and the data storage. Switching between
the Flatpak and a normal installation requires re-registering the device and
previously downloaded drawings may become inaccessible.
License
-------
Tuhi is licensed under the GPLv2 or later.
Registering devices
-------------------
@ -50,411 +104,6 @@ Packages
Arch Linux: [tuhi-git](https://aur.archlinux.org/packages/tuhi-git/)
Installation
------------
```
$> git clone http://github.com/tuhiproject/tuhi
$> cd tuhi
$> python3 setup.py install
$> tuhi
```
Tuhi requires Python v3.6 or above.
Units used by this interface
----------------------------
* Physical distances for x/y axes are in µm from the sensor's top-left
position. (Note that on the Spark and on the Slate at least, the sensor
is turned 90 degrees clockwise, so (0,0) is at the 'natural' top-right
corner)
* Stylus pressure is normalized to a range of [0, 0xffff], inclusive.
* Timestamps are in seconds in unix epoch, time offsets are in ms after the
most recent timestamp.
DBus Interface
--------------
The following interfaces are provided:
```
org.freedesktop.tuhi1.Manager
Property: Devices (ao)
Array of object paths to known (previously registered, but not necessarily
connected) devices. Note that a "registered" device is one that has been
initialized via the Wacom SmartPad custom protocol. A device does not
need to be paired over Bluetooth to register.
Property: Searching (b)
Indicates whether the daemon is currently searching for devices.
This property is set to True when a StartSearching() request initiates
the search for device connections. When the StartSearching() request
completes upon timeout, or when StopSearching() is called, the property
is set to False.
When a pariable device is found, the UnregisteredDevice signal is sent to
the caller that initiated the search process.
Read-only
Property: JSONDataVersions (au)
Specifies the JSON file format versions the server supports. The
client must request one of these versions in Device.GetJSONData().
Read-only, constant
Method: StartSearch() -> ()
Start searching for available devices ready for registering
for an unspecified timeout. When the timeout expires or an error
occurs, a SearchStopped signal is sent indicating success or error.
If a client that successfully initated a listening process calls
StartSearching() again, that call is ignored and no signal is
generated for that call.
Method: StopSearch() -> ()
Stop listening to available devices ready for registering. If called after
StartSearch() and before a SearchStopped signal has been received,
this method triggers the SearchStopped signal. That signal indicates
success or an error.
If this method is called before StartSearch() or after the
SearchStopped signal, it is ignored and no signal is generated.
Note that between calling StopSearch() and the SearchStopped signal
arriving, UnregisteredDevice signals may still arrive.
Signal: UnregisteredDevice(o)
Indicates that a device can be registered. This signal may be
sent after a StartSearch() call and before SearchStopped(). This
signal is sent once per available device and only to the client that
initiated the search process with StartSearch.
When this signal is sent, a org.freedesktop.tuhi1.Device object was
created, the object path is the argument to this signal.
A client must immediately call Register() on that object if
registering with that object is desired. See the documentation for
that interface for details.
When the search timeout expires, the device may be removed by the
daemon again. Note that until the device is registered, the device is not
listed in the managers Devices property.
Signal: SearchStopped(i)
Sent when the search has stopped. An argument of 0 indicates a
successful termination of the search process, either when a device
has been registered or the timeout expired.
If the errno is -EAGAIN, the daemon is already searching for devices
on behalf of another client. In this case, this client should wait for
the Searching property to change and StartSearching() once the
property is set to False.
Once this signal has been sent, all devices announced through
UnregisteredDevice signals should be considered invalidated. Attempting to
Register() one of the devices after the SearchStopped() signal may result
in an error.
In case of error, the argument is a negative errno.
org.freedesktop.tuhi1.Device
Interface to a device known by tuhi. Each object in Manager.Devices
implements this interface.
Property: BlueZDevice (o)
Object path to the org.bluez.Device1 device that is this device.
Read-only
Property: Dimensions (uu)
The physical dimensions (width, height) in µm
Read-only
Property: BatteryPercent (u)
The last known battery charge level in percent. This charge level is
only accurate when the BatteryState is other than Unknown.
When the BatteryState is Unknown and BatteryPercent is nonzero, the
value is the last known percentage value.
Read-only
Property: BatteryState (u)
An enum describing the battery state. Permitted enum values are
0: Unknown
1: Charging
2: Discharging
'Unknown' may refer to a state that could not be read, a state
that has not yet been updated, or a state that has not updated within
a daemon-internal time period. Thus, a device that is connected but
does not regularly send battery updates may eventually switch to
'Unknown'.
Read-only
Property: DrawingsAvailable (at)
An array of timestamps of the available drawings. The timestamp of
each drawing can be used as argument to GetJSONData(). Timestamps are
in seconds since the Epoch and may be used to display information to
the user or sort data.
Read-only
Property: Listening (b)
Indicates whether the daemon is currently listening for the device.
This property is set to True when a StartListening() request initiates
the search for device connections. When the StartListening() request
completes upon timeout, or when StopListening() is called, the property
is set to False.
When the user press the button on the device, the daemon connects
to the device, downloads all drawings from the device and disconnects
from the device.
If successfull, the drawings are deleted from the device. The data is
held by the daemon in non-persistent storage until the daemon is stopped
or we run out of memory, whichever happens earlier.
Use GetJSONData() to retrieve the data from the daemon.
DO NOT RELY ON THE DAEMON FOR PERMANENT STORAGE
When drawings become available from the device, the DrawingsAvailable
property updates to the number of available drawings.
When the button is pressed multiple times, any new data is appended
to the existing list of drawings as long as this property is True.
Read-only
Property: Live(b)
Indicates whether the device is currently in Live mode. When in live
mode, the device does not store drawings internally for a later sync
but instead fowards the events immediately, similar to a traditional
graphics tablet. See StartLive() for more details.
Read-only
Method: Register() -> (i)
Register the device. If the device is already registered, calls to
this method immediately return success.
Otherwise, the device is registered and this function returns success (0)
or a negative errno on failure.
Method: StartListening() -> ()
Listen for data from this device and connect to the device when it
becomes available. The daemon listens to the device until the client
calls StopListening() or the client disconnects, whichever happens
earlier.
The ListeningStopped signal is sent when the listening terminates,
either on success or with an error. A client should handle this signal
to be notified of any errors.
When the daemon starts listening, the Listening property is updated
accordingly.
If a client that successfully initated a listening process calls
StartListening() again, that call is ignored and no signal is
generated for that call.
Method: StopListening() -> ()
Stop listening for data on this device. If called after
StartListening(), this method triggers the ListenStopped signal.
That signal indicates success or an error.
If this method is called before StartListening() or after the
ListeningStopped signal, it is ignored and no signal is generated.
Note that between calling StopListening() and the ListeningStopped
signal arriving, the property DrawingsAvailable may still be updated
and it's the responsibility of the client to fetch the JSON data.
Method: StartLive(fd: h) -> (i)
Starts live mode on this device. This disables offline storage of
drawing data on the device and instead switches the device to a mode
where it immediately reports the pen data, similar to a traditional
graphics tablet.
The LiveStopped signal is sent when live mode terminates, either on
success or with an error. A client should handle this signal to be
notified of any errors.
When live mode enables, the Live property is updated accordingly.
If a client that successfully initated a listening process calls
StartListening() again, that call is ignored and no signal is
generated for that call.
The fd argument is a file descriptor that will be used to forward
events to. The format is the one used by the Linux kernel's UHID
device, see linux/uhid.h for details.
Method: StopLive() - >()
Stop live mode on this device. If called after StartLive(), this
method triggers the LiveStopped signal. That signal indicates
success or an error.
If this method is called before StartLive() or after the LiveStopped
signal, it is ignored and no signal is generated.
Note that between calling StopLive() and the LiveStopped signal
arriving, the device may still send events. It's the responsibility of
the client to handle events until the LiveStopped signal arrives.
Method: GetJSONData(file-version: u, timestamp: t) -> (s)
Returns a JSON file with the drawings specified by the timestamp
argument. The requested timestamp must be one of the entries in the
DrawingsAvailable property value. The file-version argument specifies
the file format version the client requests. See section JSON FILE
FORMAT for the format of the returned data.
Returns a string representing the JSON data from the last drawings or
the empty string if the timestamp is not available or the file format
version is outside the server-supported range advertised in
Manager.JSONDataVersions.
Signal: ButtonPressRequired()
Sent when the user is expected to press the physical button on the
device. A client should display a notification in response, if the
user does not press the button during the (firmware-specific) timeout
the current operation will fail.
Signal: ListeningStopped(i)
Sent when the listen process has stopped. An argument of 0 indicates a
successful termination, i.e. in response to the client calling
StopListening(). Otherwise, the argument is a negative errno
indicating the type of error.
If the errno is -EAGAIN, the daemon is already listening to the device
on behalf of another client. In this case, this client should wait for
the Listening property to change and StartListening() once the
property is set to False.
If the error is -EBADE, the device is not ready for registering/in
listening mode and registration/listening was requested. In
this case, the client should indicate to the user that the device
needs to be registered first or switched to listening mode.
If the error is -EACCES, the device is not registered with the daemon
or incorrectly registered. This may happen when the device was
registered with another host since the last connection.
The following other errnos may be sent by the daemon:
-EPROTO: the daemon has encountered a protocol error with the device.
-ETIME: timeout while communicating with the device.
These errnos indicate a bug in the daemon, and the client should
display a message to that effect.
Signal: LiveStopped(i)
Sent when live mode is stopped. An argument of 0 indicates a
successful termination, i.e. in response to the client calling
StopLive(). Otherwise, the argument is a negative errno
indicating the type of error.
If the errno is -EAGAIN, the daemon has already enabled live mode on
device on behalf of another client. In this case, this client should
wait for the Live property to change and StartLive() once the property
is set to False.
If the error is -EBADE, the device is not ready for live mode, most
likely because it is in registration mode. In this case, the client
should indicate to the user that the device needs to be registered
first.
If the error is -EACCES, the device is not registered with the daemon
or incorrectly registered. This may happen when the device was
registered with another host since the last connection.
The following other errnos may be sent by the daemon:
-EPROTO: the daemon has encountered a protocol error with the device.
-ETIME: timeout while communicating with the device.
These errnos indicate a bug in the daemon, and the client should
display a message to that effect.
Signal: SyncState(i)
An enum to represent the current synchronization state of the device.
When on (1), Tuhi is currently trying to download data from the
device. When off (0), Tuhi is not currently connecting to the device.
This signal should be used for UI feedback.
This signal is only send when the device is **not** in Live mode.
```
JSON File Format
----------------
The current file format version is 1. A server may only support a subset of
historical file formats, this subset is advertized as list of versions in
the **org.freedesktop.tuhi1.Manager.JSONDataVersions** property. Likewise, a
client may only support a subset of the possible formats. A client should
always pick the highest format supported by both the client and the server.
Below is the example file format (with comments, not present in the real
files). The JSON objects are "drawing" (the root object), "strokes",
"points". Pseudo-code is used to illustrate the objects in the file.
```
class Drawing {
version: uint32
devicename: string
dimensions: [uint32, uint32] // x/y physical dimensions in µm
timestamp: uint64
strokes: [ Stroke, Stroke, ...]
}
```
The **strokes** list contains all strokes of a single drawing, each stroke
consisting of a number of **points**.
```
class Stroke {
points: [Point, Point, ...]
}
```
The **points** list contains the actual pen data.
```
class Point {
toffset: uint32
position: [uint32, uint32]
pressure: uint32
}
```
An expanded file looks like this:
```
{
"version" : 1, // JSON file format version number
"devicename": "Wacom Bamboo Spark",
"dimensions": [ 100000, 200000], // width/height in µm
"timestamp" : 12345,
"strokes" : [
{
"points": [
// all items in a point are optional. Unknown dictionary
// entries must be ignored as future devices may add
// new axes.
{ "toffset" : 12366, "position" : [ 100, 200 ], "pressure" : 1000 },
{ "toffset" : 12368, "pressure" : 800 },
{ "toffset" : 12366, "position" : [ 120, 202 ] },
]
},
{ "points" : ... }
]
}
```
Device notes
============

150
meson.build Normal file
View File

@ -0,0 +1,150 @@
project('tuhi',
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'
# 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')
libexecdir = join_paths(get_option('prefix'), get_option('libexecdir'), 'tuhi')
i18n = import('i18n')
subdir('po')
subdir('tuhigui/data')
# 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__', 'data'])
install_subdir('tuhi',
install_dir: python_dir,
exclude_directories: '__pycache__')
# We have three startup scripts:
# - tuhi: starts server and GUI
# - tuhi-gui: starts the GUI only
# - tuhi-server: starts the server only
#
# tuhi-server can run as-is, we don't need meson for it. But for the other
# two we build a {name}.devel version that uses the in-tree files.
# For that we need to replace a few paths, in the installed versions we just
# use the normal dirs.
#
config_tuhi = configuration_data()
config_tuhi.set_quoted('libexecdir', libexecdir)
config_tuhi.set('devel', '')
config_tuhi_devel = configuration_data()
config_tuhi_devel.set_quoted('libexecdir', '')
config_tuhi_devel.set('devel', '''
tuhi_gui = '@1@/tuhi-gui.devel'
tuhi_server = '@0@/tuhi-server.py'
print('Running from source tree, using local files')
'''.format(meson.source_root(), meson.build_root()))
config_tuhigui = configuration_data()
config_tuhigui.set('pkgdatadir', pkgdatadir)
config_tuhigui.set('localedir', localedir)
config_tuhigui.set('devel', '')
config_tuhigui_devel = config_tuhigui
config_tuhigui_devel.set('pkgdatadir', join_paths(meson.build_root(), 'tuhigui', '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(), meson.build_root()))
configure_file(input: 'tuhi.in',
output: 'tuhi',
configuration: config_tuhi,
install_dir: bindir)
configure_file(input: 'tuhi.in',
output: 'tuhi.devel',
configuration: config_tuhi_devel)
configure_file(input: 'tuhi-gui.in',
output: 'tuhi-gui',
configuration: config_tuhigui,
install_dir: libexecdir)
configure_file(input: 'tuhi-gui.in',
output: 'tuhi-gui.devel',
configuration: config_tuhigui_devel)
configure_file(input: 'tuhi-server.py',
output: 'tuhi-server',
copy: true,
install_dir: libexecdir)
meson.add_install_script('meson_install.sh')
desktop_file = i18n.merge_file(input: 'tuhigui/data/org.freedesktop.Tuhi.desktop.in',
output: 'org.freedesktop.Tuhi.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/tuhi')
conf.set('version_date', version_date)
appdata_intl = configure_file(input: 'tuhigui/data/org.freedesktop.Tuhi.appdata.xml.in.in',
output: 'org.freedesktop.Tuhi.appdata.xml.in',
configuration: conf)
appdata = i18n.merge_file(input: appdata_intl,
output: 'org.freedesktop.Tuhi.appdata.xml',
type: 'xml',
po_dir: podir,
install: true,
install_dir: metainfodir)
install_data('tuhigui/data/org.freedesktop.Tuhi.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)

11
meson_install.sh Normal file
View File

@ -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

67
org.freedesktop.Tuhi.json Normal file
View File

@ -0,0 +1,67 @@
{
"app-id": "org.freedesktop.Tuhi",
"runtime": "org.gnome.Platform",
"runtime-version": "3.30",
"sdk": "org.gnome.Sdk",
"command": "tuhi",
"finish-args": [
"--share=ipc",
"--socket=x11",
"--socket=wayland",
"--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": "meson",
"sources": [
{
"type": "git",
"url": "."
}
]
}
]
}

View File

@ -1,107 +0,0 @@
{
"id": "org.freedesktop.tuhi",
"command": "/app/bin/tuhi",
"runtime": "org.freedesktop.Platform",
"sdk": "org.freedesktop.Sdk",
"runtime-version": "18.08",
"cleanup": [ "/cache",
"/include",
"/lib/pkgconfig",
"/man",
"/share/aclocal",
"/share/devhelp",
"/share/gir-1.0",
"/share/gtk-doc",
"/share/man",
"/share/pkgconfig",
"/share/vala",
"/lib/systemd",
"*.la", "*.a" ],
"build-options" : {
"cflags": "-O2 -g",
"cxxflags": "-O2 -g"
},
"finish-args": [
"--system-talk-name=org.bluez",
"--own-name=org.freedesktop.tuhi1"
],
"modules": [
{
"name": "pygobject",
"buildsystem": "simple",
"build-commands": [
"python3 setup.py install --prefix=/app"
],
"build-options": {
"env": {
"PYGOBJECT_WITHOUT_PYCAIRO" : "1"
}
},
"sources": [
{
"type": "archive",
"url": "https://ftp.gnome.org/pub/GNOME/sources/pygobject/3.32/pygobject-3.32.1.tar.xz",
"sha256": "32c99def94b8dea5ce9e4bc99576ef87591ea779b4db77cfdca7af81b76d04d8"
}
]
},
{
"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",
"path": "."
}
],
"build-commands": [
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ."
],
"post-install": [
"cp tools/kete.py /app/lib/python3.7/site-packages/kete.py",
"cp tools/tuhi-kete-sandboxed.py /app/bin/tuhi-kete",
"chmod +x /app/bin/tuhi-kete"
]
}
]
}

1
po/LINGUAS Normal file
View File

@ -0,0 +1 @@
# Language list must be in alphabetical order

17
po/POTFILES Normal file
View File

@ -0,0 +1,17 @@
data/org.freedesktop.Tuhi.appdata.xml.in.in
data/org.freedesktop.Tuhi.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

37
po/README.md Normal file
View File

@ -0,0 +1,37 @@
i18n
====
This directory contains the translations of Tuhi
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 tuhi-pot
# Now you can optionally remove the build directory
$ rm -rf translation-build
$ cp po/tuhi.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/tuhi/blob/master/tuhigui/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 tuhi-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.

1
po/meson.build Normal file
View File

@ -0,0 +1 @@
i18n.gettext(meson.project_name(), preset: 'glib')

32
tools/tuhi-gui-flatpak.py Executable file
View File

@ -0,0 +1,32 @@
#!/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('tuhi-gui')
if __name__ == '__main__':
tuhi = Process(target=start_tuhi)
tuhi.daemon = True
tuhi.start()
tuhigui = Process(target=start_tuhigui)
tuhigui.start()
tuhigui.join()

70
tuhi-gui.in Executable file
View File

@ -0,0 +1,70 @@
#!/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@', 'tuhi.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"""
flowboxchild:selected {
background-color: white;
}
.bg-white {
background-color: white;
}
.bg-paper {
border-radius: 5px;
background-color: #ebe9e8;
}
.drawing {
background-color: white;
border-radius: 5px;
}
"""
screen = Gdk.Screen.get_default()
if screen is None:
print('Error: Unable to connect to screen. Make sure DISPLAY or WAYLAND_DISPLAY are set', file=sys.stderr)
sys.exit(1)
style_provider = Gtk.CssProvider()
style_provider.load_from_data(css)
Gtk.StyleContext.add_provider_for_screen(
screen,
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('tuhi', '@localedir@')
locale.textdomain('tuhi')
gettext.bindtextdomain('tuhi', '@localedir@')
gettext.textdomain('tuhi')
signal.signal(signal.SIGINT, signal.SIG_DFL)
exit_status = Application().run(sys.argv)
sys.exit(exit_status)

29
tuhi.in Executable file
View File

@ -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 os
import subprocess
tuhi_server = os.path.join(@libexecdir@, 'tuhi-server')
tuhi_gui = os.path.join(@libexecdir@, 'tuhi-gui')
@devel@
if __name__ == '__main__':
tuhi = subprocess.Popen(tuhi_server)
try:
subprocess.run(tuhi_gui)
except KeyboardInterrupt:
pass
tuhi.terminate()

View File

@ -284,6 +284,8 @@ class Tuhi(GObject.Object):
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'device-connected':
(GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'terminate':
(GObject.SignalFlags.RUN_FIRST, None, ()),
}
def __init__(self):
@ -302,7 +304,6 @@ class Tuhi(GObject.Object):
self.devices = {}
self._search_stop_handler = None
self.mainloop = GLib.MainLoop()
def _on_tuhi_bus_name_acquired(self, dbus_server):
self.bluez.connect_to_bluez()
@ -315,7 +316,7 @@ class Tuhi(GObject.Object):
lambda mgr, dev: self._add_device(mgr, dev, True))
def _on_tuhi_bus_name_lost(self, dbus_server):
self.mainloop.quit()
self.emit('terminate')
def _on_start_search_requested(self, dbus_server, stop_handler):
self._search_stop_handler = stop_handler
@ -415,9 +416,6 @@ class Tuhi(GObject.Object):
else:
self.bluez.stop_discovery()
def run(self):
self.mainloop.run()
def main(args=sys.argv):
if sys.version_info < (3, 6):
@ -435,6 +433,9 @@ def main(args=sys.argv):
logger.setLevel(logging.DEBUG)
try:
Tuhi().run()
mainloop = GLib.MainLoop()
tuhi = Tuhi()
tuhi.connect('terminate', lambda tuhi: mainloop.quit())
mainloop.run()
except KeyboardInterrupt:
pass

50
tuhigui/README.md Normal file
View File

@ -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.

12
tuhigui/__init__.py Normal file
View File

@ -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.
#

60
tuhigui/application.py Normal file
View File

@ -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.Tuhi',
flags=Gio.ApplicationFlags.FLAGS_NONE)
GLib.set_application_name('Tuhi')
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/Tuhi/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())

128
tuhigui/config.py Normal file
View File

@ -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('tuhi.gui.config')
ROOT_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi')
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

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
version="1.1"
id="svg10"
sodipodi:docname="input-tablet-missing-symbolic.svg"
inkscape:version="0.92.4 (unknown)">
<metadata
id="metadata16">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs14" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1016"
id="namedview12"
showgrid="false"
inkscape:zoom="29.5"
inkscape:cx="15.281847"
inkscape:cy="7.8893951"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<g
color="#000"
font-weight="400"
font-family="sans-serif"
white-space="normal"
fill="#2e3436"
id="g8"
style="fill:#474747;fill-opacity:1;opacity:0.35">
<path
d="M12.403 4.15L7.45 9.102 7 11l1.87-.39 5-5c.549-.593.63-1.11.13-1.61s-1.17-.278-1.597.15z"
style="line-height:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;isolation:auto;mix-blend-mode:normal;marker:none;fill:#474747;fill-opacity:1"
clip-rule="evenodd"
overflow="visible"
fill-rule="evenodd"
image-rendering="optimizeQuality"
shape-rendering="geometricPrecision"
text-rendering="geometricPrecision"
id="path2" />
<path
d="M2.191 6L.263 15h15.473L14 6.897 12.316 8.58l.946 4.42H2.738l1.07-5h3.33l1.999-2zm9.776 0l-2 2h.1l2-2z"
style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;fill:#474747;fill-opacity:1"
overflow="visible"
fill-rule="evenodd"
id="path4" />
<path
d="M8 0v1.5a.506.506 0 0 1-.147.356.484.484 0 0 1-.345.144H4.496a1.498 1.498 0 0 0-1.297 2.248c.266.463.761.752 1.295.752h3.01c.18 0 .34.092.43.248A.506.506 0 0 1 8 5.5V6h1v-.5A1.498 1.498 0 0 0 7.504 4h-3.01a.491.491 0 0 1-.428-.248.507.507 0 0 1 0-.504.488.488 0 0 1 .43-.248h3.012c.396 0 .777-.16 1.056-.441C8.844 2.277 9 1.897 9 1.5V0z"
style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;fill:#474747;fill-opacity:1"
overflow="visible"
id="path6" />
</g>
<path
inkscape:connector-curvature="0"
id="path4581"
overflow="visible"
style="color:#bebebe;overflow:visible;fill:#474747;marker:none"
d="m 11,11 h 1.375 L 13.5,12.094 14.594,11 H 16 v 1.469 L 14.906,13.531 16,14.594 V 16 H 14.562 L 13.5,14.937 12.437,16 H 11 V 14.594 L 12.063,13.531 11,12.47 Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

42
tuhigui/data/meson.build Normal file
View File

@ -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/tuhi')
conf.set('version_date', version_date)
about_dialog = configure_file(input: 'ui/AboutDialog.ui.in',
output: 'AboutDialog.ui',
configuration: conf)
install_data('org.freedesktop.Tuhi.svg', install_dir: icondir)
i18n.merge_file(input: 'org.freedesktop.Tuhi.desktop.in',
output: 'org.freedesktop.Tuhi.desktop',
type: 'desktop',
po_dir: podir,
install: true,
install_dir: desktopdir)
appdata = configure_file(input: 'org.freedesktop.Tuhi.appdata.xml.in.in',
output: 'org.freedesktop.Tuhi.appdata.xml.in',
configuration: conf)
i18n.merge_file(input: appdata,
output: 'org.freedesktop.Tuhi.appdata.xml',
type: 'xml',
po_dir: podir,
install: true,
install_dir: metainfodir)
gnome.compile_resources('tuhi', 'tuhi.gresource.xml',
source_dir: '.',
dependencies: [about_dialog],
gresource_bundle: true,
install: true,
install_dir: pkgdatadir)

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.freedesktop.Tuhi</id>
<metadata_license>FSFAP</metadata_license>
<project_license>GPL-2.0+</project_license>
<content_rating type="oars-1.0" />
<name>Tuhi</name>
<summary>Utility to download drawings from the Wacom Ink range of devices</summary>
<description>
<p>
Tuhi 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.
</p>
<p>
Tuhi 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 Tuhi is launched.
</p>
</description>
<kudos>
<kudo>AppMenu</kudo>
<kudo>HiDpiIcon</kudo>
<kudo>ModernToolkit</kudo>
</kudos>
<launchable type="desktop-id">org.freedesktop.Tuhi.desktop</launchable>
<screenshots>
<screenshot type="default">
<caption>The button configuraton page</caption>
<image>https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-buttonpage.png</image>
</screenshot>
<screenshot>
<caption>The LED configuraton page</caption>
<image>https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-ledpage.png</image>
</screenshot>
<screenshot>
<caption>The resolution configuraton page</caption>
<image>https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-resolutionpage.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://github.com/tuhiproject/tuhi/</url>
<url type="bugtracker">https://github.com/tuhiproject/tuhi/issues</url>
<url type="help">https://github.com/tuhiproject/tuhi/wiki</url>
<project_group>GNOME</project_group>
<translation type="gettext">tuhi</translation>
<provides>
<binary>tuhi</binary>
</provides>
<releases>
<release version="@version@" date="@version_date@"/>
</releases>
</component>

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Name=Tuhi
Comment=Utility to download drawings from the Wacom Ink range of devices
Exec=tuhi
# Translators: Do NOT translate or transliterate this text (this is an icon file name)!
Icon=org.freedesktop.Tuhi
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;

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg8"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
sodipodi:docname="org.freedesktop.Tuhi.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.49497475"
inkscape:cx="-40.019796"
inkscape:cy="593.90888"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
ry="5.1324711"
rx="5.7588129"
y="32.736401"
x="22.058018"
height="240.71727"
width="179.96317"
id="rect871"
style="fill:#d1d1d1;fill-opacity:1;stroke:#000000;stroke-width:5.85741472;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
style="fill:#f8f8f8;fill-opacity:1;stroke:#000000;stroke-width:4.56920719;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect869"
width="125.8467"
height="209.46855"
x="66.17778"
y="51.012478"
rx="4.0270891"
ry="4.4661989" />
<path
style="fill:none;stroke:#000000;stroke-width:4.07743645;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 115.44566,153.57828 c -4.40264,6.5677 -10.62856,11.54273 -17.558895,15.19366 -4.67694,2.6307 -11.830989,10.60781 -6.551949,15.24865 7.445719,1.96193 15.315034,0.39747 22.254454,-2.65498 7.10422,-3.13141 18.42523,-0.36024 16.74675,9.32995 -2.37548,7.66511 -10.18845,11.53571 -14.93484,17.43238 l -0.47636,1.28811 0.0592,1.3909"
id="path843"
inkscape:connector-curvature="0" />
<circle
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:4.56920719;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path873"
cx="43.762691"
cy="160.30058"
r="8.0724049" />
<g
id="g951"
transform="translate(-0.29412526,-24.83401)">
<g
transform="matrix(0.58929876,0.50248108,-0.50248108,0.58929876,143.7747,-3.51721)"
id="g830">
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect819"
width="16.570711"
height="104.50238"
x="116.52951"
y="81.046227"
ry="5.7670002"
rx="5.1999998" />
<path
sodipodi:nodetypes="scccccs"
inkscape:connector-curvature="0"
id="path821"
d="m 123.14735,202.7875 c -1.68724,-0.16712 -3.66116,-6.50505 -3.66116,-6.50505 l -6.29172,-10.73384 7.46411,0.0819 12.44164,-0.0819 -5.80754,9.44957 c 0,0 -2.71186,7.9313 -4.14533,7.78932 z"
inkscape:transform-center-y="2.8731452"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0.83245854,0,0,0.99999988,22.299806,-5.8799047)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect825"
width="7.2162771"
height="7.2162771"
x="121.07309"
y="78.106255"
rx="5.1999884"
ry="5.7669902" />
</g>
<path
inkscape:connector-curvature="0"
id="path929"
d="m 126.29892,160.02213 44.11346,-51.29277"
style="fill:none;stroke:#878787;stroke-width:1.59743071;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/freedesktop/Tuhi">
<file preprocess="xml-stripblanks">AboutDialog.ui</file>
<file preprocess="xml-stripblanks">ui/Drawing.ui</file>
<file preprocess="xml-stripblanks">ui/DrawingPerspective.ui</file>
<file preprocess="xml-stripblanks">ui/Flowbox.ui</file>
<file preprocess="xml-stripblanks">ui/MainWindow.ui</file>
<file preprocess="xml-stripblanks">ui/SetupPerspective.ui</file>
<file preprocess="xml-stripblanks">ui/ErrorPerspective.ui</file>
<file preprocess="xml-stripblanks">input-tablet-missing-symbolic.svg</file>
</gresource>
</gresources>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.0 -->
<interface domain="tuhi">
<requires lib="gtk+" version="3.4"/>
<object class="GtkAboutDialog" id="about_dialog">
<property name="can_focus">False</property>
<property name="modal">True</property>
<property name="type_hint">normal</property>
<property name="program_name">Tuhi</property>
<property name="version">@version@</property>
<property name="copyright">Copyright © 2019 Tuhi Developers</property>
<property name="website">@url@</property>
<property name="website_label" translatable="yes">Visit Tuhis website</property>
<property name="logo_icon_name">org.freedesktop.Tuhi</property>
<property name="license_type">gpl-2-0</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can_focus">False</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
<child>
<placeholder/>
</child>
</object>
</interface>

151
tuhigui/data/ui/Drawing.ui Normal file
View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkImage" id="icon_download">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">document-save-as-symbolic</property>
</object>
<object class="GtkImage" id="icon_remove">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">edit-delete-symbolic</property>
</object>
<object class="GtkImage" id="image_rotate_left">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">object-rotate-left-symbolic</property>
</object>
<object class="GtkImage" id="image_rotate_right">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">object-rotate-right-symbolic</property>
</object>
<template class="Drawing" parent="GtkEventBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="enter-notify-event" handler="_on_enter" swapped="no"/>
<signal name="leave-notify-event" handler="_on_leave" swapped="no"/>
<child>
<object class="GtkBox" id="box_drawing">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">20</property>
<property name="margin_right">20</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="box_toolbar">
<property name="height_request">20</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="hexpand">True</property>
<child>
<object class="GtkButton" id="btn_rotate_left">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="image">image_rotate_left</property>
<signal name="clicked" handler="_on_rotate_button_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="btn_rotate_right">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="image">image_rotate_right</property>
<signal name="clicked" handler="_on_rotate_button_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="btn_download">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="image">icon_download</property>
<signal name="clicked" handler="_on_download_button_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="orientation">vertical</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkButton" id="btn_remove">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="image">icon_remove</property>
<signal name="clicked" handler="_on_delete_button_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkImage" id="image_svg">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_bottom">10</property>
<property name="stock">gtk-missing-image</property>
<style>
<class name="bg-paper"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<style>
<class name="drawing"/>
</style>
</object>
</child>
</template>
</interface>

View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="DrawingPerspective" parent="GtkStack">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="baseline_position">top</property>
<child>
<object class="GtkOverlay">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport" id="viewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox" id="box_all_drawings">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<style>
<class name="bg-white"/>
</style>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="index">-1</property>
</packing>
</child>
<child type="overlay">
<object class="GtkRevealer" id="overlay_undo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="transition_type">none</property>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_left">12</property>
<property name="margin_right">4</property>
<property name="margin_start">12</property>
<property name="margin_end">4</property>
<child>
<object class="GtkButton" id="notification_delete_undo">
<property name="label" translatable="yes">Undo delete drawing</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="margin_right">6</property>
<property name="margin_end">6</property>
<signal name="clicked" handler="_on_undo_clicked" swapped="no"/>
<style>
<class name="text-button"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="notification_delete_close">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">True</property>
<property name="relief">none</property>
<signal name="clicked" handler="_on_undo_close_clicked" swapped="no"/>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">window-close-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
<style>
<class name="image-button"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<style>
<class name="app-notification"/>
</style>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_sync">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Press the button on the device to synchronize drawings</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="name">page0</property>
<property name="title" translatable="yes">page0</property>
</packing>
</child>
</template>
</interface>

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="ErrorPerspective" parent="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<child>
<placeholder/>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">TuhiGUI is an interactive GUI to download data from Tuhi.
Tuhi connects to tablets of the Wacom Ink range. It allows you to download the drawings stored on those devices as SVGs for processing later.
Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect to it. Connecting to the DBus server should take less than a second. If you read this far, your Tuhi DBus server is not running or responsing and needs to be restarted.</property>
<property name="wrap">True</property>
<property name="max_width_chars">55</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">20</property>
<property name="margin_bottom">20</property>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_right">30</property>
<property name="active">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Connecting to Tuhi</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<child>
<placeholder/>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">This should take less than a second. Make sure the Tuhi DBus server is running.</property>
<property name="wrap">True</property>
<property name="max_width_chars">55</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</template>
</interface>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="Flowbox" parent="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="label_date">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="margin_left">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="hexpand">False</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="size" value="20000"/>
</attributes>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">20</property>
<property name="margin_right">20</property>
<property name="margin_bottom">20</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkFlowBox" id="flowbox_drawings">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="homogeneous">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</template>
</interface>

View File

@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkAdjustment" id="adjustment_zoom">
<property name="upper">15</property>
<property name="step_increment">1.0000000002235174</property>
<property name="page_increment">1</property>
<signal name="value-changed" handler="_on_zoom_changed" swapped="no"/>
</object>
<template class="MainWindow" parent="GtkApplicationWindow">
<property name="can_focus">False</property>
<property name="default_width">1000</property>
<property name="default_height">700</property>
<child type="titlebar">
<object class="GtkHeaderBar" id="headerbar">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="show_close_button">True</property>
<child>
<object class="GtkImage" id="image_missing_tablet">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="resource">/org/freedesktop/Tuhi/input-tablet-missing-symbolic.svg</property>
</object>
</child>
<child>
<object class="GtkSpinner" id="spinner_sync">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
</object>
<packing>
<property name="position">1</property>
</packing>
</child>
<child type="title">
<placeholder/>
</child>
<child>
<object class="GtkMenuButton" id="menubutton1">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">open-menu-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkImage" id="image_battery">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_right">10</property>
<property name="icon_name">battery-missing-symbolic</property>
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">zoom-out-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScale" id="scale_zoom">
<property name="width_request">100</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="adjustment">adjustment_zoom</property>
<property name="round_digits">0</property>
<property name="draw_value">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">zoom-in-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">3</property>
</packing>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="stack_perspectives">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="transition_type">crossfade</property>
<child>
<placeholder/>
</child>
</object>
</child>
</template>
</interface>

View File

@ -0,0 +1,275 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="SetupDialog" parent="GtkDialog">
<property name="can_focus">False</property>
<property name="title" translatable="yes">Initial Device Setup</property>
<property name="modal">True</property>
<property name="window_position">center-on-parent</property>
<property name="destroy_with_parent">True</property>
<property name="type_hint">dialog</property>
<property name="gravity">center</property>
<child type="titlebar">
<placeholder/>
</child>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
<property name="layout_style">end</property>
<child>
<placeholder/>
</child>
<child>
<object class="GtkButton" id="btn_quit">
<property name="label" translatable="yes">Quit</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_bottom">100</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="homogeneous">True</property>
<property name="baseline_position">bottom</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label" translatable="yes">Hold the button on the device until the blue light is flashing.</property>
<property name="justify">center</property>
<property name="wrap">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_right">20</property>
<property name="active">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Searching for device</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="name">page0</property>
<property name="title" translatable="yes">page0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="homogeneous">True</property>
<property name="baseline_position">bottom</property>
<child>
<object class="GtkLabel" id="label_devicename_p1">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="label" translatable="yes">Connecting to LE Paper</property>
<property name="wrap">True</property>
<property name="max_width_chars">55</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_right">20</property>
<property name="active">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Connecting to device...</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">page1</property>
<property name="title" translatable="yes">page1</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="homogeneous">True</property>
<property name="baseline_position">bottom</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label" translatable="yes">Press the button on the device now!</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_right">20</property>
<property name="active">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">waiting for reply</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="name">page2</property>
<property name="title" translatable="yes">page2</property>
<property name="position">2</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<action-widgets>
<action-widget response="-6">btn_quit</action-widget>
</action-widgets>
</template>
</interface>

145
tuhigui/drawing.py Normal file
View File

@ -0,0 +1,145 @@
#!/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, GdkPixbuf, Gdk
from .config import Config
from .svg import JsonSvg
import gi
gi.require_version("Gtk", "3.0")
@Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/Drawing.ui')
class Drawing(Gtk.EventBox):
__gtype_name__ = "Drawing"
box_toolbar = 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
Config.instance().connect('notify::orientation', self._on_orientation_changed)
self.json_data = json_data
self._zoom = 0
self.refresh() # sets self.svg
self.timestamp = self.svg.timestamp
self.box_toolbar.set_opacity(0)
def _on_orientation_changed(self, config, pspec):
self.orientation = config.orientation
self.refresh()
def refresh(self):
self.svg = JsonSvg(self.json_data, self.orientation)
width, height = -1, -1
if 'portrait' in self.orientation:
height = 1000
else:
width = 1000
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename=self.svg.filename,
width=width,
height=height,
preserve_aspect_ratio=True)
self.redraw()
def redraw(self):
ratio = self.pixbuf.get_height() / self.pixbuf.get_width()
base = 250 + self.zoom * 50
if 'portrait' in self.orientation:
width = base / ratio
height = base
else:
width = base
height = base * ratio
pb = self.pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
self.image_svg.set_from_pixbuf(pb)
@GObject.Property
def name(self):
return "drawing"
@GObject.Property
def zoom(self):
return self._zoom
@zoom.setter
def zoom(self, zoom):
if zoom == self._zoom:
return
self._zoom = zoom
self.redraw()
@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()
@Gtk.Template.Callback('_on_enter')
def _on_enter(self, *args):
self.box_toolbar.set_opacity(100)
@Gtk.Template.Callback('_on_leave')
def _on_leave(self, drawing, event):
if event.detail == Gdk.NotifyType.INFERIOR:
return
self.box_toolbar.set_opacity(0)

View File

@ -0,0 +1,184 @@
#!/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, 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('tuhi.gui.drawingperspective')
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/Flowbox.ui")
class Flowbox(Gtk.Box):
__gtype_name__ = "Flowbox"
label_date = Gtk.Template.Child()
flowbox_drawings = Gtk.Template.Child()
def __init__(self, timestruct, *args, **kwargs):
super().__init__(*args, **kwargs)
self.time = timestruct
self.label_date.set_text(time.strftime('%B %Y', self.time))
def insert(self, drawing):
# 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)
def delete(self, drawing):
def delete_matching_child(child, drawing):
if child.get_child() == drawing:
self.flowbox_drawings.remove(child)
self.flowbox_drawings.foreach(delete_matching_child, drawing)
@GObject.property
def is_empty(self):
return not self.flowbox_drawings.get_children()
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/DrawingPerspective.ui")
class DrawingPerspective(Gtk.Stack):
__gtype_name__ = "DrawingPerspective"
viewport = Gtk.Template.Child()
overlay_undo = Gtk.Template.Child()
notification_delete_undo = Gtk.Template.Child()
notification_delete_close = Gtk.Template.Child()
box_all_drawings = Gtk.Template.Child()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.known_drawings = {} # type {timestamp: Drawing()}
self.flowboxes = {}
self._zoom = 0
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):
def _hash(drawing):
return time.strftime('%Y%m', time.gmtime(drawing.timestamp))
for js in sorted(config.drawings, reverse=True, key=lambda j: j['timestamp']):
ts = js['timestamp']
if ts in self.known_drawings:
continue
drawing = Drawing(js)
self.known_drawings[ts] = drawing
# Now pick the right monthly flowbox to insert into
key = _hash(drawing)
try:
fb = self.flowboxes[key]
except KeyError:
fb = Flowbox(time.gmtime(drawing.timestamp))
self.flowboxes[key] = fb
self.box_all_drawings.add(fb)
finally:
fb.insert(drawing)
# Remove deleted drawings
deleted = [ts for ts in self.known_drawings if ts not in [js['timestamp'] for js in config.drawings]]
for ts in deleted:
drawing = self.known_drawings[ts]
fb = self.flowboxes[_hash(drawing)]
fb.delete(drawing)
if fb.is_empty:
del self.flowboxes[_hash(drawing)]
self.box_all_drawings.remove(fb)
del self.known_drawings[ts]
self.notification_delete_undo.deleted_drawing = drawing.timestamp
self.overlay_undo.set_reveal_child(True)
@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)
# 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._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"
@GObject.Property
def zoom(self):
return self._zoom
@zoom.setter
def zoom(self, zoom):
if zoom == self._zoom:
return
self._zoom = zoom
for ts, drawing in self.known_drawings.items():
drawing.zoom = zoom
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)

89
tuhigui/svg.py Normal file
View File

@ -0,0 +1,89 @@
#!/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, 'tuhi', 'svg')
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
# Pressure normalized range is [0, 0xffff]
delta = (p['pressure'] - 0x8000) / 0x8000
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()

397
tuhigui/tuhi.py Normal file
View File

@ -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.gui.dbus')
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]

243
tuhigui/window.py Normal file
View File

@ -0,0 +1,243 @@
#!/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('tuhi.gui.window')
MENU_XML = """
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="primary-menu">
<section>
<item>
<attribute name="label" translatable="yes">Portrait</attribute>
<attribute name="action">win.orientation</attribute>
<attribute name="target">portrait</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Landscape</attribute>
<attribute name="action">win.orientation</attribute>
<attribute name="target">landscape</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Help</attribute>
<attribute name="action">app.help</attribute>
</item>
<item>
<attribute name="label" translatable="yes">About</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</menu>
</interface>
"""
@Gtk.Template(resource_path="/org/freedesktop/Tuhi/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/Tuhi/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/Tuhi/ui/MainWindow.ui')
class MainWindow(Gtk.ApplicationWindow):
__gtype_name__ = 'MainWindow'
stack_perspectives = Gtk.Template.Child()
headerbar = Gtk.Template.Child()
menubutton1 = Gtk.Template.Child()
spinner_sync = Gtk.Template.Child()
image_battery = Gtk.Template.Child()
image_missing_tablet = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.maximize()
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)
self.headerbar.set_title(f'Tuhi')
self.stack_perspectives.set_visible_child_name(dp.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:
device = self._tuhi.devices[0]
self._init_device(device)
dp.device = device
self.headerbar.set_title(f'Tuhi - {dp.device.name}')
def _init_device(self, device):
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)
self._on_battery_changed(device, None)
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):
self.image_missing_tablet.set_visible(False)
if device.sync_state:
self.spinner_sync.start()
else:
self.spinner_sync.stop()
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._init_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
@Gtk.Template.Callback('_on_zoom_changed')
def _on_zoom_changed(self, adjustment):
dp = self._get_child('drawing_perspective')
dp.zoom = int(adjustment.get_value())