plover-touch-tablets 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: plover-touch-tablets
3
+ Version: 0.0.0
4
+ Summary: Touch Tablets Plover Plugin
5
+ Keywords: plover_plugin
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: plover>=5.0.0
8
+ Requires-Dist: PySide6
9
+ Requires-Dist: websocket-client
10
+ Requires-Dist: jsonpickle
11
+ Requires-Dist: nacl_middleware
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Provides-Extra: dev
15
+ Requires-Dist: plover-touch-tablets[test]; extra == "dev"
16
+ Requires-Dist: pre-commit; extra == "dev"
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: bump-my-version; extra == "dev"
19
+ Requires-Dist: twine; extra == "dev"
20
+ Requires-Dist: ruff; extra == "dev"
21
+
22
+ # Plover Touch Tablets Plugin
23
+
24
+ This Plover plugin enables seamless integration between Plover and touch-based steno keyboard applications running on tablets. It establishes a secure WebSocket connection, allowing your tablet to function as a steno machine for Plover.
25
+
26
+ ## Compatibility
27
+
28
+ This plugin is designed to be compatible with the **[Touch Steno Keyboard](https://github.com/CosmicDNA/touch-steno-keyboard)** application and the **[Plover Websocket Relay](https://github.com/CosmicDNA/plover-websocket-relay)**.
29
+
30
+ ## Features
31
+
32
+ - **Easy Pairing**: Generates a QR code within Plover for quick connection setup.
33
+ - **Secure Connection**: Uses encryption to ensure secure communication between the tablet and Plover.
34
+ - **Bi-directional Communication**: Supports sending steno strokes to Plover and receiving dictionary lookups/translations back on the tablet.
35
+
36
+ ## Installation
37
+
38
+ 1. Open Plover.
39
+ 2. Install `plover-touch-tablets` plugin via either plover_console CLI or plugin manager.
40
+ 3. Restart Plover.
41
+
42
+ ## Usage
43
+
44
+ 1. Open Plover.
45
+ 2. Go to **Tools** > **Tablet QR**.
46
+ 3. A window will appear displaying a QR code.
47
+ 4. Open the Touch Steno Keyboard application on your tablet.
48
+ 5. Use the application to scan the QR code displayed on your computer screen.
49
+ 6. Once connected, strokes entered on the tablet will be processed by Plover.
50
+
51
+ ## Powered by
52
+ ![Python](https://img.shields.io/badge/Python-repo?logo=python&color=black&style=for-the-badge)
53
+ ![Plover](https://img.shields.io/badge/Plover-repo?logo=plover&color=black&style=for-the-badge)
@@ -0,0 +1,32 @@
1
+ # Plover Touch Tablets Plugin
2
+
3
+ This Plover plugin enables seamless integration between Plover and touch-based steno keyboard applications running on tablets. It establishes a secure WebSocket connection, allowing your tablet to function as a steno machine for Plover.
4
+
5
+ ## Compatibility
6
+
7
+ This plugin is designed to be compatible with the **[Touch Steno Keyboard](https://github.com/CosmicDNA/touch-steno-keyboard)** application and the **[Plover Websocket Relay](https://github.com/CosmicDNA/plover-websocket-relay)**.
8
+
9
+ ## Features
10
+
11
+ - **Easy Pairing**: Generates a QR code within Plover for quick connection setup.
12
+ - **Secure Connection**: Uses encryption to ensure secure communication between the tablet and Plover.
13
+ - **Bi-directional Communication**: Supports sending steno strokes to Plover and receiving dictionary lookups/translations back on the tablet.
14
+
15
+ ## Installation
16
+
17
+ 1. Open Plover.
18
+ 2. Install `plover-touch-tablets` plugin via either plover_console CLI or plugin manager.
19
+ 3. Restart Plover.
20
+
21
+ ## Usage
22
+
23
+ 1. Open Plover.
24
+ 2. Go to **Tools** > **Tablet QR**.
25
+ 3. A window will appear displaying a QR code.
26
+ 4. Open the Touch Steno Keyboard application on your tablet.
27
+ 5. Use the application to scan the QR code displayed on your computer screen.
28
+ 6. Once connected, strokes entered on the tablet will be processed by Plover.
29
+
30
+ ## Powered by
31
+ ![Python](https://img.shields.io/badge/Python-repo?logo=python&color=black&style=for-the-badge)
32
+ ![Plover](https://img.shields.io/badge/Plover-repo?logo=plover&color=black&style=for-the-badge)
@@ -0,0 +1,79 @@
1
+ """Server configuration."""
2
+
3
+ from json import dump, load
4
+ from pathlib import Path
5
+
6
+ from nacl.encoding import HexEncoder
7
+ from nacl.public import PrivateKey
8
+ from nacl_middleware import Nacl
9
+ from plover.oslayer.config import CONFIG_DIR
10
+
11
+ from plover_touch_tablets.get_logger import get_logger
12
+
13
+ log = get_logger("ClientConfig")
14
+
15
+
16
+ class ClientConfig:
17
+ """Loads server configuration.
18
+
19
+ Attributes:
20
+ host: The host address for the server to run on.
21
+ port: The port for the server to run on.
22
+
23
+ """
24
+
25
+ public_key: str
26
+ public_key: str
27
+
28
+ def __init__(self, client_config_file: str) -> None:
29
+ """Initialize the server configuration object.
30
+
31
+ Args:
32
+ client_config_file: The file path of the configuration file to load.
33
+
34
+ Raises:
35
+ IOError: Errored when loading the server configuration file.
36
+
37
+ """
38
+ # Try to load from the other plugin's config file first (read-only)
39
+ other_config_path = Path(CONFIG_DIR) / client_config_file
40
+ data = {}
41
+ keys_found = False
42
+
43
+ if other_config_path.exists():
44
+ try:
45
+ with other_config_path.open(encoding="utf-8") as config_file:
46
+ other_data = load(config_file)
47
+ if "private_key" in other_data and "public_key" in other_data:
48
+ data = other_data
49
+ keys_found = True
50
+ log.debug(f"Loaded keys from {other_config_path}")
51
+ except Exception:
52
+ log.exception(f"Error reading {other_config_path}:")
53
+
54
+ # If not found, use our own config file
55
+ if not keys_found:
56
+ my_config_path = Path(CONFIG_DIR) / "plover_touch_tablets.json"
57
+ if my_config_path.exists():
58
+ try:
59
+ with my_config_path.open(encoding="utf-8") as config_file:
60
+ data = load(config_file)
61
+ if "private_key" in data and "public_key" in data:
62
+ keys_found = True
63
+ log.debug(f"Loaded keys from {my_config_path}")
64
+ except Exception:
65
+ log.exception(f"Error reading {my_config_path}:")
66
+
67
+ if not keys_found:
68
+ log.info("No existing keys found. Generating new keys...")
69
+ nacl_helper = Nacl()
70
+ data["private_key"] = nacl_helper.decoded_private_key()
71
+ data["public_key"] = nacl_helper.decoded_public_key()
72
+
73
+ log.info(f"Saving new keys to {my_config_path}")
74
+ with my_config_path.open("w", encoding="utf-8") as config_file:
75
+ dump(data, config_file, indent=2)
76
+
77
+ # Assign values to class attributes
78
+ self.private_key = PrivateKey(data["private_key"], HexEncoder)
79
+ self.public_key = data["public_key"]
@@ -0,0 +1,16 @@
1
+ # from plover_touch_tablets.debug import is_debug_mode
2
+
3
+ # if is_debug_mode():
4
+ # PROTOCOL = "http:"
5
+ # BASE_WORKER_URL = "localhost:8787"
6
+
7
+ WORKER_PROTOCOL = "https:"
8
+ BASE_WORKER_FQDN = "relay.stenography.cosmicdna.co.uk"
9
+ APP_URL = "https://touch.stenography.cosmicdna.co.uk"
10
+
11
+ SESSION_SLUG = "session"
12
+ INITIATE_SLUG = "initiate"
13
+ CONNECT_SLUG = "connect"
14
+ JOIN_SLUG = "join"
15
+ TOKEN_PARAM = "token" # nosec B105
16
+ RELAY_PARAM = "relay"
@@ -0,0 +1,7 @@
1
+ import logging
2
+
3
+ plover_logger = logging.getLogger("plover")
4
+
5
+
6
+ def is_debug_mode():
7
+ return plover_logger.isEnabledFor(logging.DEBUG)
@@ -0,0 +1,5 @@
1
+ import urllib.parse
2
+
3
+
4
+ def encode_raw_url(url: str) -> str:
5
+ return urllib.parse.quote(url, safe="")
@@ -0,0 +1,29 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from plover.engine import StenoEngine
4
+
5
+ if TYPE_CHECKING:
6
+ from plover_touch_tablets.extension import Extension
7
+
8
+ from plover_touch_tablets.signal import Signal
9
+
10
+
11
+ class ExtendedStenoEngine:
12
+ my_minimal_extension: "Extension"
13
+ signals: list[Signal]
14
+ _engine: StenoEngine
15
+
16
+ def __init__(self, engine: StenoEngine):
17
+ self._engine = engine
18
+
19
+ def __getattr__(self, name):
20
+ """Wrapper around StenoEngine."""
21
+ return getattr(self._engine, name)
22
+
23
+ def disconnect_hooks(self, host: object):
24
+ for signal in self.signals:
25
+ self._engine.hook_disconnect(signal.hook, getattr(host, signal.callback))
26
+
27
+ def connect_hooks(self, host: object):
28
+ for signal in self.signals:
29
+ self._engine.hook_connect(signal.hook, getattr(host, signal.callback))
@@ -0,0 +1,171 @@
1
+ import json
2
+ from collections.abc import Callable
3
+ from importlib.metadata import metadata
4
+ from typing import Any
5
+
6
+ from jsonpickle import encode
7
+ from nacl_middleware import MailBox
8
+ from plover.engine import StenoEngine
9
+ from plover.gui_qt.paper_tape import TapeModel
10
+ from plover.steno import Stroke
11
+ from websocket import WebSocketApp
12
+
13
+ from plover_touch_tablets.client_config import ClientConfig
14
+ from plover_touch_tablets.config import BASE_WORKER_FQDN, WORKER_PROTOCOL
15
+ from plover_touch_tablets.extended_engine import ExtendedStenoEngine
16
+ from plover_touch_tablets.get_logger import get_logger
17
+ from plover_touch_tablets.lookup import lookup
18
+ from plover_touch_tablets.signal import Signal
19
+
20
+ log = get_logger("Extension")
21
+
22
+ SERVER_CONFIG_FILE = "plover_websocket_server_config.json"
23
+
24
+
25
+ class Extension:
26
+ engine: ExtendedStenoEngine
27
+ _tape_model: TapeModel
28
+
29
+ def __init__(self, engine: StenoEngine):
30
+ self.engine = ExtendedStenoEngine(engine)
31
+ engine.my_minimal_extension = self
32
+
33
+ self.engine.signals = [Signal("stroked"), Signal("translated")]
34
+ self._config = ClientConfig(SERVER_CONFIG_FILE) # reload the configuration when the server is restarted
35
+ self.mail_boxes: dict[int, MailBox] = {}
36
+
37
+ self._tape_model = TapeModel()
38
+ self._tape_model.reset()
39
+
40
+ def on_stroked(self, stroke: Stroke):
41
+ # Minimal example: just log strokes
42
+ log.info(f"Stroke: {stroke}")
43
+
44
+ def on_translated(self, old, new):
45
+ if new:
46
+ log.info(f"Translated: {new}")
47
+
48
+ def start(self):
49
+ log.info("Extension initialised")
50
+
51
+ # Example: Connect to stroke signals
52
+ self.engine.connect_hooks(self)
53
+
54
+ def stop(self):
55
+ self.engine.disconnect_hooks(self)
56
+
57
+ def _handle_tablet_connected(
58
+ self,
59
+ ws: WebSocketApp,
60
+ tablet_id: int,
61
+ public_key: str,
62
+ on_tablet_connected: Callable[[str], None] | None,
63
+ new_tablet_token: str | None,
64
+ ):
65
+ log.debug(f"Private key: {self._config.private_key} and public key: {public_key}")
66
+ if public_key:
67
+ self.mail_boxes[tablet_id] = MailBox(self._config.private_key, public_key)
68
+ ws.send(
69
+ json.dumps(
70
+ {
71
+ "to": {"type": "tablet", "id": tablet_id},
72
+ "payload": {
73
+ "message": "Here is my the public key for you to privately communicate with me...",
74
+ "public_key": self._config.public_key,
75
+ },
76
+ }
77
+ )
78
+ )
79
+ if on_tablet_connected and new_tablet_token:
80
+ on_tablet_connected(new_tablet_token)
81
+
82
+ def _handle_stroke(self, ws: WebSocketApp, tablet_id: int, tablet_mail_box: MailBox, steno_keys: list):
83
+ try:
84
+ stroke = Stroke(steno_keys)
85
+ stroke_json = encode(stroke, unpicklable=False)
86
+ paper = self._tape_model._paper_format(stroke)
87
+
88
+ data = {
89
+ "keys": stroke.steno_keys,
90
+ "stroked": stroke_json,
91
+ "rtfcre": stroke.rtfcre,
92
+ "paper": paper,
93
+ }
94
+ message = {"on_stroked": data}
95
+
96
+ ws.send(
97
+ json.dumps(
98
+ {
99
+ "to": {"type": "tablet", "id": tablet_id},
100
+ "payload": tablet_mail_box.box(message),
101
+ }
102
+ )
103
+ )
104
+
105
+ self.engine._engine._machine_stroke_callback(steno_keys)
106
+ except Exception:
107
+ log.exception("Failed to process stroke")
108
+
109
+ def _handle_lookup(self, ws: WebSocketApp, tablet_id: int, tablet_mail_box: MailBox, text_to_lookup: str):
110
+ try:
111
+ steno_options_per_word = lookup(self.engine._engine, text_to_lookup)
112
+ ws.send(
113
+ json.dumps(
114
+ {
115
+ "to": {"type": "tablet", "id": tablet_id},
116
+ "payload": tablet_mail_box.box({"lookup": steno_options_per_word}),
117
+ }
118
+ )
119
+ )
120
+ except Exception:
121
+ log.exception("Failed to process lookup request")
122
+
123
+ def connect_websocket(self, connection_string: str, on_tablet_connected: Callable[[str], None] | None = None):
124
+ def on_message(ws: WebSocketApp, message: Any):
125
+ if isinstance(message, str):
126
+ message: dict = json.loads(message)
127
+ log.debug(f"Received: {message}")
128
+ msg_type = message.get("type")
129
+ if msg_type == "tablet_connected":
130
+ tablet_id = message.get("id")
131
+ public_key = message.get("publicKey")
132
+ new_tablet_token = message.get("newTabletToken")
133
+ self._handle_tablet_connected(ws, tablet_id, public_key, on_tablet_connected, new_tablet_token)
134
+ return
135
+
136
+ from_data: dict = message.get("from")
137
+ if from_data and from_data.get("type") == "tablet":
138
+ payload = message.get("payload")
139
+ tablet_id = from_data.get("id")
140
+ tablet_mail_box = self.mail_boxes.get(tablet_id)
141
+ decrypted_payload = tablet_mail_box.unbox(payload)
142
+
143
+ if "stroke" in decrypted_payload:
144
+ steno_keys = decrypted_payload["stroke"]
145
+ if isinstance(steno_keys, list):
146
+ self._handle_stroke(ws, tablet_id, tablet_mail_box, steno_keys)
147
+
148
+ if "lookup" in decrypted_payload:
149
+ text_to_lookup = decrypted_payload["lookup"]
150
+ log.debug(f"Lookup request for: {text_to_lookup}")
151
+ if isinstance(text_to_lookup, str):
152
+ self._handle_lookup(ws, tablet_id, tablet_mail_box, text_to_lookup)
153
+
154
+ def on_error(ws, error: Exception):
155
+ log.exception(f"Error: {error}")
156
+
157
+ def on_close(ws, close_status_code, close_msg):
158
+ log.info("Closed")
159
+
160
+ def on_open(ws):
161
+ log.info("Opened")
162
+
163
+ meta = metadata("plover-touch-tablets")
164
+ header = {
165
+ "User-Agent": f"{meta['Name']}/{meta['Version']}",
166
+ "Origin": f"{WORKER_PROTOCOL}//{BASE_WORKER_FQDN}",
167
+ "X-Public-Key": self._config.public_key,
168
+ }
169
+ log.info(header)
170
+ ws = WebSocketApp(connection_string, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, header=header)
171
+ ws.run_forever(reconnect=5)
@@ -0,0 +1,14 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def get_logger(name: str, level=logging.DEBUG):
6
+ log = logging.getLogger(f"plover.{name.lower()}")
7
+ if not log.handlers:
8
+ handler = logging.StreamHandler(sys.stdout)
9
+ handler.setFormatter(logging.Formatter(f"%(asctime)s [%(threadName)s] %(levelname)s: [{name}] %(filename)s:%(lineno)d %(message)s"))
10
+ log.addHandler(handler)
11
+ log.setLevel(level)
12
+ log.propagate = False
13
+
14
+ return log
@@ -0,0 +1,147 @@
1
+ # Find the steno strokes for the given text
2
+ import re
3
+
4
+ from plover import log
5
+ from plover.engine import StenoEngine
6
+
7
+
8
+ class PloverLookupError(Exception):
9
+ def __init__(self, msg, *args):
10
+ self.msg = msg
11
+ super().__init__(*args)
12
+
13
+
14
+ def lookup(engine: StenoEngine, text_to_lookup: str) -> list: # noqa: PLR0915
15
+ """Recursively looks up a phrase by finding the longest possible dictionary match.
16
+
17
+ Starts from the beginning of the string and then solving for the remainder.
18
+
19
+ A lookup can fail (return an empty list) if any part of the tokenized input
20
+ string cannot be found in Plover's dictionaries. The lookup is performed
21
+ recursively, and if any segment of the phrase has no corresponding steno
22
+ strokes, the entire lookup for that path will fail, and if no alternative
23
+ paths are found, the overall result will be empty.
24
+ """
25
+ memo = {}
26
+ log.debug(f"Starting lookup for: '{text_to_lookup}'")
27
+
28
+ def get_steno_for_phrase(phrase: str) -> list | None:
29
+ """Finds steno for a phrase.
30
+
31
+ Handling capitalization by falling back
32
+ to lowercase and prepending the capitalization stroke.
33
+ """
34
+ # 1. Try the phrase as-is (respecting capitalization)
35
+ log.debug(f" - get_steno_for_phrase('{phrase}')")
36
+
37
+ steno_capitalized: set = engine.reverse_lookup(phrase)
38
+
39
+ # If the phrase is a single non-word character (like '!'),
40
+ # also try looking it up as a Plover command (e.g., '{!}').
41
+ if len(phrase) == 1 and not phrase.isalnum():
42
+ # Handle characters that are special within Plover's command syntax
43
+ command_phrase = f"{{{phrase}}}"
44
+ log.debug(f" - Trying command lookup for '{command_phrase}'")
45
+ steno_from_command = engine.reverse_lookup(command_phrase)
46
+ if steno_from_command:
47
+ steno_capitalized.update(steno_from_command)
48
+
49
+ # 2. Try the lowercase version and prepend cap stroke if needed
50
+ steno_lowercase_modified = set()
51
+ if phrase.lower() != phrase:
52
+ steno_lowercase_raw = engine.reverse_lookup(phrase.lower())
53
+ if steno_lowercase_raw:
54
+ cap_next_results = ("KPA",)
55
+ steno_lowercase_modified = {cap_next_results + s_lower for s_lower in steno_lowercase_raw}
56
+
57
+ # Prioritize direct capitalized results
58
+ combined = steno_capitalized.union(steno_lowercase_modified)
59
+ numeric_phrase = re.sub(r"[$,€£]", "", phrase.replace(",", ""))
60
+ if numeric_phrase.isdigit():
61
+ digit_steno_list = []
62
+ all_digits_found = True
63
+ for digit in numeric_phrase:
64
+ digit_steno = engine.reverse_lookup(digit)
65
+ if not digit_steno:
66
+ all_digits_found = False
67
+ break
68
+ digit_steno_list.append(min(digit_steno, key=len)) # Choose shortest steno for the digit
69
+ if all_digits_found:
70
+ combined_digit_steno = tuple(s for steno_tuple in digit_steno_list for s in steno_tuple)
71
+ combined.add(combined_digit_steno)
72
+
73
+ # If after all attempts, we have no results, return None.
74
+ if not combined:
75
+ # Only issue a warning for single words that are not found, as this is the root cause of failure.
76
+ is_single_word = " " not in phrase
77
+ if is_single_word:
78
+ log.warning(f"Failed to find steno for word: '{phrase}'")
79
+ else:
80
+ log.debug(f" - FAILED to find steno for phrase: '{phrase}'")
81
+ return None
82
+
83
+ # Sort results: 1. Direct cap match, 2. Stroke count, 3. Key count
84
+ return sorted(combined, key=lambda s: (s not in steno_capitalized, len(s), sum(len(p) for p in s)))
85
+
86
+ def solve(words_tuple: tuple) -> list[list[tuple]]:
87
+ if not words_tuple:
88
+ return [[]] # Base case: one valid solution, which is empty.
89
+ if words_tuple in memo:
90
+ return memo[words_tuple]
91
+ log.debug(f"--> solve({words_tuple})")
92
+
93
+ def get_steno_options(i):
94
+ return get_steno_for_phrase(" ".join(words_tuple[:i]))
95
+
96
+ max_lookup_length = min(len(words_tuple), engine._dictionaries.longest_key)
97
+
98
+ def process_i(i, best_steno_for_prefix):
99
+ # Recursively find all solutions for the rest of the phrase
100
+ prefix_phrase = " ".join(words_tuple[:i])
101
+ suffix_tuple = words_tuple[i:]
102
+ suffix_solutions = solve(suffix_tuple)
103
+
104
+ # Combine the prefix's steno with each suffix solution
105
+ return [[{"text": prefix_phrase, "steno": best_steno_for_prefix}] + suffix_solution for suffix_solution in suffix_solutions]
106
+
107
+ all_solutions = [
108
+ solution
109
+ for i in range(max_lookup_length, 0, -1)
110
+ if (steno_options := get_steno_options(i))
111
+ for solution in process_i(i, steno_options[0])
112
+ ]
113
+
114
+ if not all_solutions:
115
+ # This is the point of failure. It means for the current `words_tuple`,
116
+ # no prefix could be found in the dictionary that also had a valid suffix solution.
117
+ log.debug(f" <-- solve({words_tuple}) -> FAILED: No steno found for any prefix.")
118
+ memo[words_tuple] = all_solutions
119
+ return all_solutions
120
+
121
+ # Tokenize the input string, separating words from punctuation.
122
+ # This finds sequences of word characters (including those with internal apostrophes)
123
+ # currency symbols attached to numbers, numbers with commas, or single non-word/non-space characters.
124
+ token_regex = r"[$€£]?\d+(?:,\d+)*|\w+(?:['’]\w+)*|[^\w\s]" # nosec B105
125
+ words = re.findall(token_regex, text_to_lookup)
126
+
127
+ all_possible_sequences = solve(tuple(words))
128
+
129
+ log.debug(f"All possible sequences: {all_possible_sequences}")
130
+
131
+ if not all_possible_sequences:
132
+ log.debug(f"Lookup failed for '{text_to_lookup}'. No valid steno sequence found.")
133
+ return []
134
+
135
+ # Sort the collected sequences by overall efficiency
136
+ # 1. Total number of strokes in the sequence
137
+ # 2. Total number of keys pressed in the sequence
138
+ sorted_sequences = sorted(
139
+ all_possible_sequences,
140
+ key=lambda seq: (
141
+ sum(len(item["steno"]) for item in seq),
142
+ sum(sum(len(p) for p in item["steno"]) for item in seq),
143
+ ),
144
+ )
145
+
146
+ log.debug(f"Lookup finished. Returning {sorted_sequences}")
147
+ return sorted_sequences
@@ -0,0 +1,7 @@
1
+ class Signal:
2
+ hook: str
3
+ callback: callable
4
+
5
+ def __init__(self, name: str) -> None:
6
+ self.hook = name
7
+ self.callback = f"on_{name}"
@@ -0,0 +1,134 @@
1
+ import threading
2
+ from io import BytesIO
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ import qrcode
7
+ import requests
8
+ from plover.engine import StenoEngine
9
+ from plover.gui_qt.tool import Tool
10
+ from plover.oslayer.config import ASSETS_DIR
11
+ from PySide6.QtCore import QObject, Qt, Signal
12
+ from PySide6.QtGui import QImage, QPixmap
13
+ from PySide6.QtWidgets import QLabel, QVBoxLayout
14
+
15
+ from plover_touch_tablets.config import (
16
+ APP_URL,
17
+ BASE_WORKER_FQDN,
18
+ CONNECT_SLUG,
19
+ INITIATE_SLUG,
20
+ JOIN_SLUG,
21
+ RELAY_PARAM,
22
+ SESSION_SLUG,
23
+ TOKEN_PARAM,
24
+ WORKER_PROTOCOL,
25
+ )
26
+ from plover_touch_tablets.encoding import encode_raw_url
27
+ from plover_touch_tablets.extended_engine import ExtendedStenoEngine
28
+ from plover_touch_tablets.get_logger import get_logger
29
+
30
+ if TYPE_CHECKING:
31
+ from plover_touch_tablets.extension import Extension
32
+
33
+ log = get_logger("Tool")
34
+
35
+
36
+ class Main(Tool):
37
+ TITLE = "Tablet QR"
38
+ ICON = str(Path(ASSETS_DIR) / "plover.png")
39
+ ROLE = "connection_settings"
40
+
41
+ _engine: ExtendedStenoEngine
42
+ qr: "QRCode"
43
+ extension: "Extension"
44
+ tablet_connected = Signal(str)
45
+ session_id: str
46
+
47
+ def __init__(self, engine: StenoEngine):
48
+ super().__init__(engine)
49
+ self.tablet_connected.connect(self.on_tablet_connected)
50
+ self._engine = ExtendedStenoEngine(engine)
51
+ log.info("Tool initialised")
52
+
53
+ self.qr = QRCode(self)
54
+
55
+ if not hasattr(engine, "my_minimal_extension"):
56
+ log.warning("Extension not found. Is the plugin enabled?")
57
+ return
58
+
59
+ self.extension = self._engine.my_minimal_extension
60
+ log.info("Tool successfully connected to Extension!")
61
+
62
+ self.thread_0 = threading.Thread(target=process_data, args=(self,), daemon=True)
63
+ self.thread_0.start()
64
+
65
+ def closeEvent(self, event): # noqa: N802
66
+ super().closeEvent(event)
67
+
68
+ def on_tablet_connected(self, new_token: str):
69
+ ws_protocol = WORKER_PROTOCOL.replace("http", "ws")
70
+ tablet_connection_string = f"{ws_protocol}//{BASE_WORKER_FQDN}/{SESSION_SLUG}/{self.session_id}/{JOIN_SLUG}?{TOKEN_PARAM}={new_token}"
71
+ log.info(f"Tablet connection string is {tablet_connection_string}")
72
+ final_qr_url = f"{APP_URL}/?{RELAY_PARAM}={encode_raw_url(tablet_connection_string)}"
73
+ log.info(final_qr_url)
74
+ self.qr.qr_ready.emit(final_qr_url)
75
+
76
+
77
+ def process_data(main_tool: Main):
78
+ try:
79
+ res = requests.post(f"{WORKER_PROTOCOL}//{BASE_WORKER_FQDN}/{SESSION_SLUG}/{INITIATE_SLUG}", timeout=10)
80
+ response: dict = res.json()
81
+ except Exception:
82
+ log.exception("Request failed")
83
+ else:
84
+ # protocol = response["protocol"]
85
+ sessionId = response["sessionId"]
86
+ pcConnectionToken = response["pcConnectionToken"]
87
+ tabletConnectionToken = response["tabletConnectionToken"]
88
+ connection_infos = [
89
+ (pcConnectionToken, CONNECT_SLUG, WORKER_PROTOCOL, BASE_WORKER_FQDN),
90
+ (tabletConnectionToken, JOIN_SLUG, WORKER_PROTOCOL, BASE_WORKER_FQDN),
91
+ ]
92
+
93
+ connection_strings = [
94
+ f"{connection_info[2].replace('http', 'ws')}//{connection_info[3]}/{SESSION_SLUG}/{sessionId}/{connection_info[1]}?{TOKEN_PARAM}={
95
+ connection_info[0]
96
+ }"
97
+ for connection_info in connection_infos
98
+ ]
99
+
100
+ log.info(f"Pc connection string is {connection_strings[0]}")
101
+
102
+ tablet_connection_string = connection_strings[1]
103
+ log.info(f"Tablet connection string is {tablet_connection_string}")
104
+
105
+ final_qr_url = f"{APP_URL}/?{RELAY_PARAM}={encode_raw_url(tablet_connection_string)}"
106
+
107
+ log.info(final_qr_url)
108
+
109
+ if main_tool:
110
+ main_tool.session_id = sessionId
111
+ main_tool.qr.qr_ready.emit(final_qr_url)
112
+ main_tool.extension.connect_websocket(connection_strings[0], on_tablet_connected=main_tool.tablet_connected.emit)
113
+
114
+
115
+ class QRCode(QObject):
116
+ qr_label: QLabel
117
+ qr_ready = Signal(str)
118
+
119
+ def __init__(self, tool: Main) -> None:
120
+ super().__init__()
121
+ self.tool = tool
122
+ self.qr_label = QLabel("Generating QR Code...")
123
+ self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
124
+ layout = QVBoxLayout()
125
+ layout.addWidget(self.qr_label)
126
+ tool.setLayout(layout)
127
+ self.qr_ready.connect(self.update_qr_code)
128
+
129
+ def update_qr_code(self, connection_string):
130
+ img = qrcode.make(connection_string)
131
+ buffer = BytesIO()
132
+ img.save(buffer, format="PNG")
133
+ qimage = QImage.fromData(buffer.getvalue())
134
+ self.qr_label.setPixmap(QPixmap.fromImage(qimage))
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: plover-touch-tablets
3
+ Version: 0.0.0
4
+ Summary: Touch Tablets Plover Plugin
5
+ Keywords: plover_plugin
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: plover>=5.0.0
8
+ Requires-Dist: PySide6
9
+ Requires-Dist: websocket-client
10
+ Requires-Dist: jsonpickle
11
+ Requires-Dist: nacl_middleware
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Provides-Extra: dev
15
+ Requires-Dist: plover-touch-tablets[test]; extra == "dev"
16
+ Requires-Dist: pre-commit; extra == "dev"
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: bump-my-version; extra == "dev"
19
+ Requires-Dist: twine; extra == "dev"
20
+ Requires-Dist: ruff; extra == "dev"
21
+
22
+ # Plover Touch Tablets Plugin
23
+
24
+ This Plover plugin enables seamless integration between Plover and touch-based steno keyboard applications running on tablets. It establishes a secure WebSocket connection, allowing your tablet to function as a steno machine for Plover.
25
+
26
+ ## Compatibility
27
+
28
+ This plugin is designed to be compatible with the **[Touch Steno Keyboard](https://github.com/CosmicDNA/touch-steno-keyboard)** application and the **[Plover Websocket Relay](https://github.com/CosmicDNA/plover-websocket-relay)**.
29
+
30
+ ## Features
31
+
32
+ - **Easy Pairing**: Generates a QR code within Plover for quick connection setup.
33
+ - **Secure Connection**: Uses encryption to ensure secure communication between the tablet and Plover.
34
+ - **Bi-directional Communication**: Supports sending steno strokes to Plover and receiving dictionary lookups/translations back on the tablet.
35
+
36
+ ## Installation
37
+
38
+ 1. Open Plover.
39
+ 2. Install `plover-touch-tablets` plugin via either plover_console CLI or plugin manager.
40
+ 3. Restart Plover.
41
+
42
+ ## Usage
43
+
44
+ 1. Open Plover.
45
+ 2. Go to **Tools** > **Tablet QR**.
46
+ 3. A window will appear displaying a QR code.
47
+ 4. Open the Touch Steno Keyboard application on your tablet.
48
+ 5. Use the application to scan the QR code displayed on your computer screen.
49
+ 6. Once connected, strokes entered on the tablet will be processed by Plover.
50
+
51
+ ## Powered by
52
+ ![Python](https://img.shields.io/badge/Python-repo?logo=python&color=black&style=for-the-badge)
53
+ ![Plover](https://img.shields.io/badge/Plover-repo?logo=plover&color=black&style=for-the-badge)
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ plover_touch_tablets/client_config.py
4
+ plover_touch_tablets/config.py
5
+ plover_touch_tablets/debug.py
6
+ plover_touch_tablets/encoding.py
7
+ plover_touch_tablets/extended_engine.py
8
+ plover_touch_tablets/extension.py
9
+ plover_touch_tablets/get_logger.py
10
+ plover_touch_tablets/lookup.py
11
+ plover_touch_tablets/signal.py
12
+ plover_touch_tablets/tool.py
13
+ plover_touch_tablets.egg-info/PKG-INFO
14
+ plover_touch_tablets.egg-info/SOURCES.txt
15
+ plover_touch_tablets.egg-info/dependency_links.txt
16
+ plover_touch_tablets.egg-info/entry_points.txt
17
+ plover_touch_tablets.egg-info/requires.txt
18
+ plover_touch_tablets.egg-info/top_level.txt
19
+ tests/test_me.py
@@ -0,0 +1,5 @@
1
+ [plover.extension]
2
+ plover_touch_tablets = plover_touch_tablets.extension:Extension
3
+
4
+ [plover.gui.qt.tool]
5
+ tablet_qr = plover_touch_tablets.tool:Main
@@ -0,0 +1,16 @@
1
+ plover>=5.0.0
2
+ PySide6
3
+ websocket-client
4
+ jsonpickle
5
+ nacl_middleware
6
+
7
+ [dev]
8
+ plover-touch-tablets[test]
9
+ pre-commit
10
+ build
11
+ bump-my-version
12
+ twine
13
+ ruff
14
+
15
+ [test]
16
+ pytest
@@ -0,0 +1 @@
1
+ plover_touch_tablets
@@ -0,0 +1,104 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "plover-touch-tablets"
7
+ version = "0.0.0"
8
+ description = "Touch Tablets Plover Plugin"
9
+ readme = "README.md"
10
+ keywords = ["plover_plugin"]
11
+ dependencies = [
12
+ "plover>=5.0.0",
13
+ "PySide6",
14
+ "websocket-client",
15
+ "jsonpickle",
16
+ "nacl_middleware"
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ test = ["pytest"]
21
+
22
+ dev = [
23
+ "plover-touch-tablets[test]", # Includes all dependencies from the 'test' group,
24
+ "pre-commit", # For managing pre-commit hooks
25
+ "build",
26
+ "bump-my-version",
27
+ "twine",
28
+ "ruff",
29
+ ]
30
+
31
+ [project.entry-points."plover.gui.qt.tool"]
32
+ tablet_qr = "plover_touch_tablets.tool:Main"
33
+
34
+ [project.entry-points."plover.extension"]
35
+ plover_touch_tablets = "plover_touch_tablets.extension:Extension"
36
+
37
+ [tool.bumpversion]
38
+ current_version = "0.0.0"
39
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
40
+ serialize = ["{major}.{minor}.{patch}"]
41
+ search = "{current_version}"
42
+ replace = "{new_version}"
43
+ regex = false
44
+ ignore_missing_version = false
45
+ ignore_missing_files = false
46
+ tag = true
47
+ sign_tags = true
48
+ tag_name = "v{new_version}"
49
+ tag_message = "Bump version: {current_version} → {new_version}"
50
+ allow_dirty = false
51
+ commit = true
52
+ message = "Bump version: {current_version} → {new_version}"
53
+ moveable_tags = []
54
+ commit_args = ""
55
+ setup_hooks = []
56
+ pre_commit_hooks = []
57
+ post_commit_hooks = []
58
+
59
+ [tool.ruff]
60
+ line-length = 149
61
+ target-version = "py311"
62
+
63
+ [tool.ruff.lint]
64
+ # Enable Pyflakes (F), pycodestyle (E, W), isort (I)
65
+ # Enable many common Pylint (PL), flake8-bugbear (B), flake8-comprehensions (C4), etc. rules
66
+ select = [
67
+ "F",
68
+ "E",
69
+ "W",
70
+ "I",
71
+ "N",
72
+ "D", # Core flake8, isort, pep8-naming, pydocstyle
73
+ "UP", # pyupgrade
74
+ "B", # flake8-bugbear
75
+ "A", # flake8-builtins
76
+ "C4", # flake8-comprehensions
77
+ "T20", # flake8-print (T201 for print, T203 for pprint)
78
+ "SIM", # flake8-simplify
79
+ "PTH", # flake8-use-pathlib
80
+ "PL", # Pylint
81
+ "TRY", # tryceratops
82
+ ]
83
+ ignore = [
84
+ # Ignore common missing docstring errors for now
85
+ "D100", # Missing docstring in public module
86
+ "D101", # Missing docstring in public class
87
+ "D102", # Missing docstring in public method
88
+ "D103", # Missing docstring in public function
89
+ "D104", # Missing docstring in public package
90
+ "D107", # Missing docstring in `__init__`
91
+ "D203", # 1 blank line required before class docstring
92
+ "D213", # Multi-line docstring closing quotes should be on a separate line
93
+ "D401", # First line of docstring should be in imperative mood
94
+ "N803", # Function name should be lowercase
95
+ "N806", # Variable in function should be lowercase
96
+ ]
97
+ # mccabe (cyclomatic complexity)
98
+ # mccabe.max-complexity = 10 # Default is 10, adjust as needed
99
+
100
+ [tool.ruff.format]
101
+ quote-style = "double" # Black default
102
+ indent-style = "space"
103
+ skip-magic-trailing-comma = false # Black default
104
+ line-ending = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ def test_suite():
2
+ async def a_simple_test():
3
+ assert True