diff --git a/.gitignore b/.gitignore index f0aa0d7..b2fee71 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ flatpak* tuhi.egg-info __pycache__ *.swp -*.svg diff --git a/DBusInterface.md b/DBusInterface.md new file mode 100644 index 0000000..09a5ee0 --- /dev/null +++ b/DBusInterface.md @@ -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. + diff --git a/README.md b/README.md index 227a081..188f307 100644 --- a/README.md +++ b/README.md @@ -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 ============ diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..774173e --- /dev/null +++ b/meson.build @@ -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) diff --git a/meson_install.sh b/meson_install.sh new file mode 100644 index 0000000..e5aa42a --- /dev/null +++ b/meson_install.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +if [ -z $DESTDIR ]; then + PREFIX=${MESON_INSTALL_PREFIX:-/usr} + + # Update icon cache + gtk-update-icon-cache -f -t $PREFIX/share/icons/hicolor + + # Install new schemas + #glib-compile-schemas $PREFIX/share/glib-2.0/schemas/ +fi diff --git a/org.freedesktop.Tuhi.json b/org.freedesktop.Tuhi.json new file mode 100644 index 0000000..cf93424 --- /dev/null +++ b/org.freedesktop.Tuhi.json @@ -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": "." + } + ] + } + ] +} diff --git a/org.freedesktop.tuhi.json b/org.freedesktop.tuhi.json deleted file mode 100644 index f1721ef..0000000 --- a/org.freedesktop.tuhi.json +++ /dev/null @@ -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" - ] - } - ] -} diff --git a/po/LINGUAS b/po/LINGUAS new file mode 100644 index 0000000..e0d25c8 --- /dev/null +++ b/po/LINGUAS @@ -0,0 +1 @@ +# Language list must be in alphabetical order diff --git a/po/POTFILES b/po/POTFILES new file mode 100644 index 0000000..d2c921d --- /dev/null +++ b/po/POTFILES @@ -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 diff --git a/po/README.md b/po/README.md new file mode 100644 index 0000000..e811a03 --- /dev/null +++ b/po/README.md @@ -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. + diff --git a/po/meson.build b/po/meson.build new file mode 100644 index 0000000..e9b77d7 --- /dev/null +++ b/po/meson.build @@ -0,0 +1 @@ +i18n.gettext(meson.project_name(), preset: 'glib') diff --git a/tools/tuhi-gui-flatpak.py b/tools/tuhi-gui-flatpak.py new file mode 100755 index 0000000..6bbdcd5 --- /dev/null +++ b/tools/tuhi-gui-flatpak.py @@ -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() diff --git a/tuhi-gui.in b/tuhi-gui.in new file mode 100755 index 0000000..b64edf2 --- /dev/null +++ b/tuhi-gui.in @@ -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) + diff --git a/tuhi.py b/tuhi-server.py similarity index 100% rename from tuhi.py rename to tuhi-server.py diff --git a/tuhi.in b/tuhi.in new file mode 100755 index 0000000..ae963b7 --- /dev/null +++ b/tuhi.in @@ -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() diff --git a/tuhi/base.py b/tuhi/base.py index 1b36470..0c84772 100644 --- a/tuhi/base.py +++ b/tuhi/base.py @@ -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 diff --git a/tuhigui/README.md b/tuhigui/README.md new file mode 100644 index 0000000..929fdb5 --- /dev/null +++ b/tuhigui/README.md @@ -0,0 +1,50 @@ +TuhiGui +======= + +Tuhi is a GUI to the Tuhi DBus daemon that connects to and fetches the data +from the Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). The data +is converted to SVG and users can save it on disk. + +For more info about Tuhi see: https://github.com/tuhiproject/tuhi + + +Building TuhiGUI +---------------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> meson builddir + $> ninja -C builddir + $> ./builddir/tuhigui.devel +``` + +TuhiGui requires Python v3.6 or above. + +Install TuhiGUI +--------------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> meson builddir + $> ninja -C builddir install + $> tuhigui +``` + +TuhiGui requires Python v3.6 or above. + +Flatpak +------- + +``` + $> git clone http://github.com/tuhiproject/tuhigui + $> cd tuhigui + $> flatpak-builder flatpak_builddir org.freedesktop.TuhiGui.json --install --user --force-clean + $> flatpak run org.freedesktop.TuhiGui +``` + +License +------- + +TuhiGui is licensed under the GPLv2 or later. diff --git a/tuhigui/__init__.py b/tuhigui/__init__.py new file mode 100644 index 0000000..8ced745 --- /dev/null +++ b/tuhigui/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# diff --git a/tuhigui/application.py b/tuhigui/application.py new file mode 100644 index 0000000..6207474 --- /dev/null +++ b/tuhigui/application.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + +from gi.repository import Gio, GLib, Gtk +from .window import MainWindow + +import gi +gi.require_version("Gio", "2.0") +gi.require_version("Gtk", "3.0") + + +class Application(Gtk.Application): + def __init__(self): + super().__init__(application_id='org.freedesktop.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()) diff --git a/tuhigui/config.py b/tuhigui/config.py new file mode 100644 index 0000000..a32ca41 --- /dev/null +++ b/tuhigui/config.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + + +from gi.repository import GObject + +import xdg.BaseDirectory +import configparser +import logging +import json +from pathlib import Path + +logger = logging.getLogger('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 diff --git a/tuhigui/data/input-tablet-missing-symbolic.svg b/tuhigui/data/input-tablet-missing-symbolic.svg new file mode 100644 index 0000000..ed6c4d0 --- /dev/null +++ b/tuhigui/data/input-tablet-missing-symbolic.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/tuhigui/data/meson.build b/tuhigui/data/meson.build new file mode 100644 index 0000000..7aac228 --- /dev/null +++ b/tuhigui/data/meson.build @@ -0,0 +1,42 @@ +gnome = import('gnome') + +desktopdir = join_paths(datadir, 'applications') +icondir = join_paths(datadir, 'icons', 'hicolor', 'scalable', 'apps') +metainfodir = join_paths(datadir, 'metainfo') + +conf = configuration_data() +conf.set('version', meson.project_version()) +conf.set('url', 'https://github.com/tuhiproject/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) diff --git a/tuhigui/data/org.freedesktop.Tuhi.appdata.xml.in.in b/tuhigui/data/org.freedesktop.Tuhi.appdata.xml.in.in new file mode 100644 index 0000000..13c6871 --- /dev/null +++ b/tuhigui/data/org.freedesktop.Tuhi.appdata.xml.in.in @@ -0,0 +1,59 @@ + + + org.freedesktop.Tuhi + FSFAP + GPL-2.0+ + + Tuhi + Utility to download drawings from the Wacom Ink range of devices + +

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

+

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

+
+ + + AppMenu + HiDpiIcon + ModernToolkit + + + org.freedesktop.Tuhi.desktop + + + + The button configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-buttonpage.png + + + The LED configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-ledpage.png + + + The resolution configuraton page + https://github.com/libratbag/piper/raw/wiki/screenshots/flathub/piper-resolutionpage.png + + + + https://github.com/tuhiproject/tuhi/ + https://github.com/tuhiproject/tuhi/issues + https://github.com/tuhiproject/tuhi/wiki + GNOME + + tuhi + + + tuhi + + + + + +
diff --git a/tuhigui/data/org.freedesktop.Tuhi.desktop.in b/tuhigui/data/org.freedesktop.Tuhi.desktop.in new file mode 100644 index 0000000..d955e63 --- /dev/null +++ b/tuhigui/data/org.freedesktop.Tuhi.desktop.in @@ -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; diff --git a/tuhigui/data/org.freedesktop.Tuhi.svg b/tuhigui/data/org.freedesktop.Tuhi.svg new file mode 100644 index 0000000..9fe6a50 --- /dev/null +++ b/tuhigui/data/org.freedesktop.Tuhi.svg @@ -0,0 +1,124 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/tuhigui/data/tuhi.gresource.xml b/tuhigui/data/tuhi.gresource.xml new file mode 100644 index 0000000..6c33ca0 --- /dev/null +++ b/tuhigui/data/tuhi.gresource.xml @@ -0,0 +1,13 @@ + + + + AboutDialog.ui + ui/Drawing.ui + ui/DrawingPerspective.ui + ui/Flowbox.ui + ui/MainWindow.ui + ui/SetupPerspective.ui + ui/ErrorPerspective.ui + input-tablet-missing-symbolic.svg + + diff --git a/tuhigui/data/ui/AboutDialog.ui.in b/tuhigui/data/ui/AboutDialog.ui.in new file mode 100644 index 0000000..2fab046 --- /dev/null +++ b/tuhigui/data/ui/AboutDialog.ui.in @@ -0,0 +1,35 @@ + + + + + + False + True + normal + Tuhi + @version@ + Copyright © 2019 Tuhi Developers + @url@ + Visit Tuhi’s website + org.freedesktop.Tuhi + gpl-2-0 + + + False + + + False + + + False + False + 0 + + + + + + + + + diff --git a/tuhigui/data/ui/Drawing.ui b/tuhigui/data/ui/Drawing.ui new file mode 100644 index 0000000..9bca55e --- /dev/null +++ b/tuhigui/data/ui/Drawing.ui @@ -0,0 +1,151 @@ + + + + + + True + False + document-save-as-symbolic + + + True + False + edit-delete-symbolic + + + True + False + object-rotate-left-symbolic + + + True + False + object-rotate-right-symbolic + + + diff --git a/tuhigui/data/ui/DrawingPerspective.ui b/tuhigui/data/ui/DrawingPerspective.ui new file mode 100644 index 0000000..be5868b --- /dev/null +++ b/tuhigui/data/ui/DrawingPerspective.ui @@ -0,0 +1,147 @@ + + + + + + diff --git a/tuhigui/data/ui/ErrorPerspective.ui b/tuhigui/data/ui/ErrorPerspective.ui new file mode 100644 index 0000000..b41965d --- /dev/null +++ b/tuhigui/data/ui/ErrorPerspective.ui @@ -0,0 +1,117 @@ + + + + + + diff --git a/tuhigui/data/ui/Flowbox.ui b/tuhigui/data/ui/Flowbox.ui new file mode 100644 index 0000000..2a3f1d3 --- /dev/null +++ b/tuhigui/data/ui/Flowbox.ui @@ -0,0 +1,56 @@ + + + + + + diff --git a/tuhigui/data/ui/MainWindow.ui b/tuhigui/data/ui/MainWindow.ui new file mode 100644 index 0000000..d72729d --- /dev/null +++ b/tuhigui/data/ui/MainWindow.ui @@ -0,0 +1,133 @@ + + + + + + 15 + 1.0000000002235174 + 1 + + + + diff --git a/tuhigui/data/ui/SetupPerspective.ui b/tuhigui/data/ui/SetupPerspective.ui new file mode 100644 index 0000000..a40e95a --- /dev/null +++ b/tuhigui/data/ui/SetupPerspective.ui @@ -0,0 +1,275 @@ + + + + + + diff --git a/tuhigui/drawing.py b/tuhigui/drawing.py new file mode 100644 index 0000000..823bf73 --- /dev/null +++ b/tuhigui/drawing.py @@ -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) diff --git a/tuhigui/drawingperspective.py b/tuhigui/drawingperspective.py new file mode 100644 index 0000000..0f4b219 --- /dev/null +++ b/tuhigui/drawingperspective.py @@ -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) diff --git a/tuhigui/svg.py b/tuhigui/svg.py new file mode 100644 index 0000000..13fdd8a --- /dev/null +++ b/tuhigui/svg.py @@ -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() diff --git a/tuhigui/tuhi.py b/tuhigui/tuhi.py new file mode 100644 index 0000000..147a2a0 --- /dev/null +++ b/tuhigui/tuhi.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + +from gi.repository import GObject, Gio, GLib +import argparse +import errno +import os +import logging +import re +import xdg.BaseDirectory + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger('tuhi.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] diff --git a/tuhigui/window.py b/tuhigui/window.py new file mode 100644 index 0000000..dc1e29f --- /dev/null +++ b/tuhigui/window.py @@ -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 = """ + + + +
+ + Portrait + win.orientation + portrait + + + Landscape + win.orientation + landscape + +
+
+ + Help + app.help + + + About + app.about + +
+
+
+""" + + +@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())