KeyboardPaster 0.1.23__tar.gz → 0.1.25__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.
Files changed (26) hide show
  1. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/PKG-INFO +7 -1
  2. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/SOURCES.txt +6 -1
  3. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/PKG-INFO +7 -1
  4. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/README.md +6 -0
  5. keyboardpaster-0.1.25/keyboardpaster/app.py +503 -0
  6. keyboardpaster-0.1.25/keyboardpaster/bootstrap.py +20 -0
  7. keyboardpaster-0.1.25/keyboardpaster/desktop.py +96 -0
  8. keyboardpaster-0.1.25/keyboardpaster/keyboard_input.py +705 -0
  9. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/keyboardpaster/keyboard_layout_detector.py +22 -0
  10. keyboardpaster-0.1.25/keyboardpaster/keyboardpaster_app.kv +324 -0
  11. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/keyboardpaster/modules/autoupdate.py +7 -2
  12. keyboardpaster-0.1.25/keyboardpaster/res/images/keyboardpaster.png +0 -0
  13. keyboardpaster-0.1.25/keyboardpaster/shared_resources.py +15 -0
  14. keyboardpaster-0.1.25/keyboardpaster.desktop +10 -0
  15. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/setup.py +8 -1
  16. keyboardpaster-0.1.23/keyboardpaster/app.py +0 -406
  17. keyboardpaster-0.1.23/keyboardpaster/keyboardpaster_app.kv +0 -173
  18. keyboardpaster-0.1.23/keyboardpaster/shared_resources.py +0 -1
  19. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/dependency_links.txt +0 -0
  20. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/entry_points.txt +0 -0
  21. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/requires.txt +0 -0
  22. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/KeyboardPaster.egg-info/top_level.txt +0 -0
  23. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/LICENSE +0 -0
  24. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/keyboardpaster/__init__.py +0 -0
  25. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/keyboardpaster/modules/__init__.py +0 -0
  26. {keyboardpaster-0.1.23 → keyboardpaster-0.1.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: KeyboardPaster
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: A Python package to type text using the keyboard module
5
5
  Home-page: https://github.com/KnutssonDevelopment/KeyboardPaster
6
6
  Author: Brian Knutsson
@@ -52,6 +52,12 @@ A Python package to type text using the keyboard module. This is useful in situa
52
52
  - Paste images as base64 code
53
53
 
54
54
  ## Changes
55
+ ### v0.1.25
56
+ - BUGFIX: Dropbox menu resulted in crash when multiple profiles were available
57
+
58
+ ### v0.1.24
59
+ - BUGFIX: Corrected Python requirement
60
+
55
61
  ### v0.1.23
56
62
  - BUGFIX: Corrected Python requirement
57
63
 
@@ -1,5 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
+ keyboardpaster.desktop
3
4
  setup.py
4
5
  KeyboardPaster.egg-info/PKG-INFO
5
6
  KeyboardPaster.egg-info/SOURCES.txt
@@ -9,8 +10,12 @@ KeyboardPaster.egg-info/requires.txt
9
10
  KeyboardPaster.egg-info/top_level.txt
10
11
  keyboardpaster/__init__.py
11
12
  keyboardpaster/app.py
13
+ keyboardpaster/bootstrap.py
14
+ keyboardpaster/desktop.py
15
+ keyboardpaster/keyboard_input.py
12
16
  keyboardpaster/keyboard_layout_detector.py
13
17
  keyboardpaster/keyboardpaster_app.kv
14
18
  keyboardpaster/shared_resources.py
15
19
  keyboardpaster/modules/__init__.py
16
- keyboardpaster/modules/autoupdate.py
20
+ keyboardpaster/modules/autoupdate.py
21
+ keyboardpaster/res/images/keyboardpaster.png
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: KeyboardPaster
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: A Python package to type text using the keyboard module
5
5
  Home-page: https://github.com/KnutssonDevelopment/KeyboardPaster
6
6
  Author: Brian Knutsson
@@ -52,6 +52,12 @@ A Python package to type text using the keyboard module. This is useful in situa
52
52
  - Paste images as base64 code
53
53
 
54
54
  ## Changes
55
+ ### v0.1.25
56
+ - BUGFIX: Dropbox menu resulted in crash when multiple profiles were available
57
+
58
+ ### v0.1.24
59
+ - BUGFIX: Corrected Python requirement
60
+
55
61
  ### v0.1.23
56
62
  - BUGFIX: Corrected Python requirement
57
63
 
@@ -10,6 +10,12 @@ A Python package to type text using the keyboard module. This is useful in situa
10
10
  - Paste images as base64 code
11
11
 
12
12
  ## Changes
13
+ ### v0.1.25
14
+ - BUGFIX: Dropbox menu resulted in crash when multiple profiles were available
15
+
16
+ ### v0.1.24
17
+ - BUGFIX: Corrected Python requirement
18
+
13
19
  ### v0.1.23
14
20
  - BUGFIX: Corrected Python requirement
15
21
 
@@ -0,0 +1,503 @@
1
+ import re
2
+ import json
3
+ import subprocess
4
+ import importlib.resources
5
+ import keyboardpaster
6
+
7
+ from keyboardpaster.bootstrap import configure_kivy_environment
8
+
9
+ configure_kivy_environment()
10
+
11
+ from kivymd.app import MDApp
12
+ from kivy.properties import ObjectProperty, StringProperty, BooleanProperty
13
+ from kivy.uix.textinput import TextInput
14
+ from kivy.lang import Builder
15
+ from kivy.clock import Clock
16
+ from kivy.core.window import Window
17
+ from kivy.factory import Factory
18
+ from kivy.metrics import dp
19
+ from kivy.uix.dropdown import DropDown
20
+ from kivymd.uix.selectioncontrol.selectioncontrol import MDCheckbox
21
+ from kivymd.uix.button.button import MDRaisedButton
22
+ from kivymd.uix.dialog import MDDialog
23
+ from kivymd.uix.textfield import MDTextField
24
+
25
+ from keyboardpaster.keyboard_input import type_string
26
+ from keyboardpaster.keyboard_layout_detector import get_keyboard_layout
27
+ from keyboardpaster.modules.autoupdate import autoupdate
28
+ from keyboardpaster.shared_resources import app_version, app_icon_path
29
+ from keyboardpaster.desktop import ensure_user_desktop_entry
30
+
31
+ SPECIAL_CHARS_SHIFT = {
32
+ 'EN_US': {
33
+ '~': '`', '!': '1', '@': '2', '#': '3', '$': '4',
34
+ '%': '5', '^': '6', '&': '7', '*': '8', '(': '9',
35
+ ')': '0', '_': '-', '+': '=', '{': '[', '}': ']',
36
+ '|': '\\', ':': ';', '"': "'", '<': ',', '>': '.',
37
+ '?': '/'
38
+ },
39
+ 'DA_DK': {
40
+ '½': '§', '!': '1', '"': '2', '#': '3', '¤': '4', '%': '5',
41
+ '&': '6', '/': '7', '(': '8', ')': '9', '=': '0',
42
+ '?': '+', '`': '§', ':': ';', ';': ',', '>': '<', '_': '/',
43
+ '*': '\\'
44
+ }
45
+ }
46
+
47
+ SPECIAL_CHARS_ALT_GR = {
48
+ 'EN_US': {},
49
+ 'DA_DK': {
50
+ '$': '4', '£': '3', '@': '2', '{': '7', '}': '0', '[': '8',
51
+ ']': '9', '|': '´', '€': 'E',
52
+ # ¨ key (physical ]); used by pynput path. ydotool uses DA_DK_YDOTOOL_COMBO.
53
+ '~': ']',
54
+ }
55
+ }
56
+
57
+ # Dynamic window sizing around slots (matches keyboardpaster_app.kv dp values)
58
+ WINDOW_WIDTH = int(dp(960))
59
+ HEADER_HEIGHT = int(dp(68))
60
+ FOOTER_HEIGHT = int(dp(72))
61
+ BODY_TOP_PADDING = int(dp(12))
62
+ SLOT_HEIGHT = int(dp(56))
63
+ SLOT_SPACING = int(dp(10))
64
+ SLOT_AREA_PADDING_V = int(dp(16)) # container padding top+bottom
65
+ MAX_WINDOW_HEIGHT = int(dp(720))
66
+
67
+ """
68
+ SPECIAL_CHARS_SPACE = {
69
+ 'EN_US': {},
70
+ 'DA_DK': {
71
+ '^': '¨'
72
+ }
73
+ }
74
+ """
75
+
76
+ def type_string_with_delay(
77
+ text: str,
78
+ start_delay: float = 3.0,
79
+ mod_delay: float = 0.1,
80
+ keypress_delay: float = 0.1,
81
+ layout: str = 'EN_US',
82
+ end_line: bool = False,
83
+ on_error=None,
84
+ ) -> None:
85
+ """
86
+ Types the given text using the keyboard module after a defined start delay, with an optional delay between keypresses.
87
+
88
+ :param text: The text to be typed.
89
+ :param start_delay: The delay before typing starts in seconds. Default is 3.0 seconds.
90
+ :param keypress_delay: The delay between keypresses in seconds. Default is 0.1 seconds.
91
+ :param layout: The keyboard layout to use. Default is 'EN_US'.
92
+ :param end_line: Should end the paste with a ENTER press.
93
+ :param on_error: Optional callback invoked if typing fails after the start delay.
94
+ """
95
+
96
+ def type_with_delay_callback(dt):
97
+ try:
98
+ special_chars_shift = SPECIAL_CHARS_SHIFT.get(layout, SPECIAL_CHARS_SHIFT["EN_US"])
99
+ special_chars_alt_gr = SPECIAL_CHARS_ALT_GR.get(layout, SPECIAL_CHARS_ALT_GR["EN_US"])
100
+ type_string(
101
+ text,
102
+ delay=keypress_delay,
103
+ mod_delay=mod_delay,
104
+ layout=layout,
105
+ end_line=end_line,
106
+ special_chars_shift=special_chars_shift,
107
+ special_chars_alt_gr=special_chars_alt_gr,
108
+ )
109
+ except Exception as error:
110
+ if on_error:
111
+ on_error(error)
112
+ else:
113
+ raise
114
+
115
+ Clock.schedule_once(type_with_delay_callback, start_delay)
116
+
117
+
118
+ class KeyboardPasterApp(MDApp):
119
+ layout = StringProperty('EN_US')
120
+ version_text = StringProperty(f"v{app_version}")
121
+ start_delay = ObjectProperty(None)
122
+ layout = 'EN_US'
123
+ _updating_slots = BooleanProperty(False)
124
+
125
+ def __init__(self):
126
+ super().__init__()
127
+ self.config = None
128
+ self.json_config = 'saved_inputs.json'
129
+ # Strong ref: KV ids only keep a WeakProxy, so DropDown was GC'd after dismiss.
130
+ self.profile_dropdown = DropDown()
131
+
132
+ def build(self):
133
+ self.theme_cls.primary_palette = "Amber"
134
+ self.theme_cls.accent_palette = "Orange"
135
+ self.theme_cls.theme_style = "Dark"
136
+ Window.clearcolor = (0.08, 0.08, 0.08, 1)
137
+
138
+ self.read_config()
139
+
140
+ self.detect_keyboard_layout()
141
+ Clock.schedule_once(self.set_profile, 1)
142
+ self.title = f"Keyboard Paster v{app_version}"
143
+ Window.size = (WINDOW_WIDTH, self._height_for_slot_count(1))
144
+ icon = app_icon_path()
145
+ if icon is not None:
146
+ Window.set_icon(str(icon))
147
+
148
+ with importlib.resources.path(keyboardpaster, "keyboardpaster_app.kv") as kv_file_path:
149
+ return Builder.load_file(str(kv_file_path))
150
+
151
+ def on_stop(self):
152
+ pass
153
+
154
+ @staticmethod
155
+ def _height_for_slot_count(slot_count: int) -> int:
156
+ n = max(1, slot_count)
157
+ slots_area = SLOT_AREA_PADDING_V + n * SLOT_HEIGHT + max(0, n - 1) * SLOT_SPACING
158
+ return HEADER_HEIGHT + BODY_TOP_PADDING + slots_area + FOOTER_HEIGHT
159
+
160
+ def update_window_height(self):
161
+ desired = self._height_for_slot_count(len(self._ordered_slots()))
162
+ height = min(desired, MAX_WINDOW_HEIGHT)
163
+ if Window.size[0] != WINDOW_WIDTH or Window.size[1] != height:
164
+ Window.size = (WINDOW_WIDTH, height)
165
+
166
+ def _slot_container(self):
167
+ return self.root.ids["input_fields_container"]
168
+
169
+ def _ordered_slots(self):
170
+ # Kivy children are reverse insertion order.
171
+ return list(reversed(self._slot_container().children))
172
+
173
+ @staticmethod
174
+ def _slot_text_input(slot):
175
+ for child in slot.children:
176
+ if isinstance(child, TextInput):
177
+ return child
178
+ return None
179
+
180
+ @staticmethod
181
+ def _slot_secret_checkbox(slot):
182
+ for child in slot.children:
183
+ if isinstance(child, MDCheckbox) and getattr(child, "secret_checkbox", False):
184
+ return child
185
+ return None
186
+
187
+ def _renumber_slots(self):
188
+ for index, slot in enumerate(self._ordered_slots()):
189
+ slot.text_input_id = f"input_text_{index}"
190
+
191
+ def add_slot(self, text: str = "", secret: bool = False):
192
+ container = self._slot_container()
193
+ index = len(container.children)
194
+ slot = Factory.InputFieldButton()
195
+ slot.text_input_id = f"input_text_{index}"
196
+ container.add_widget(slot)
197
+
198
+ text_input = self._slot_text_input(slot)
199
+ secret_cb = self._slot_secret_checkbox(slot)
200
+
201
+ previous = self._updating_slots
202
+ self._updating_slots = True
203
+ try:
204
+ if text_input is not None:
205
+ text_input.text = text
206
+ text_input.password = bool(secret)
207
+ if secret_cb is not None:
208
+ secret_cb.active = bool(secret)
209
+ finally:
210
+ self._updating_slots = previous
211
+
212
+ return slot
213
+
214
+ def clear_slots(self):
215
+ self._slot_container().clear_widgets()
216
+
217
+ def ensure_trailing_empty_slot(self, *_args):
218
+ if self._updating_slots:
219
+ return
220
+
221
+ self._updating_slots = True
222
+ try:
223
+ slots = self._ordered_slots()
224
+ if not slots:
225
+ self.add_slot()
226
+ self._renumber_slots()
227
+ return
228
+
229
+ # Remove extra trailing empty slots (keep exactly one empty at the end).
230
+ while len(slots) > 1:
231
+ last = slots[-1]
232
+ prev = slots[-2]
233
+ last_text = self._slot_text_input(last)
234
+ prev_text = self._slot_text_input(prev)
235
+ last_empty = last_text is None or not last_text.text.strip()
236
+ prev_empty = prev_text is None or not prev_text.text.strip()
237
+ if last_empty and prev_empty:
238
+ self._slot_container().remove_widget(last)
239
+ slots = self._ordered_slots()
240
+ continue
241
+ break
242
+
243
+ last = self._ordered_slots()[-1]
244
+ last_text = self._slot_text_input(last)
245
+ if last_text is not None and last_text.text.strip():
246
+ self.add_slot()
247
+
248
+ self._renumber_slots()
249
+ finally:
250
+ self._updating_slots = False
251
+ self.update_window_height()
252
+
253
+ def on_slot_text(self, _instance, _value):
254
+ self.ensure_trailing_empty_slot()
255
+
256
+ def read_config(self):
257
+ try:
258
+ with open(self.json_config, "r", encoding='utf-8') as file:
259
+ self.config = json.load(file)
260
+ except FileNotFoundError:
261
+ self.config = None
262
+ except AttributeError:
263
+ self.config = None
264
+ except json.JSONDecodeError:
265
+ self.config = None
266
+ except ValueError:
267
+ self.config = None
268
+ except KeyError:
269
+ self.config = None
270
+
271
+ if not self.config:
272
+ self.config = {'profiles': {'DEFAULT': {}}}
273
+ elif 'profiles' in self.config:
274
+ pass
275
+ elif 'input_text_0' in self.config:
276
+ values = self.config
277
+ self.config = {'profiles': {'DEFAULT': values}}
278
+ else:
279
+ self.config = {'profiles': {'DEFAULT': {}}}
280
+
281
+ def write_config(self):
282
+ with open(self.json_config, "w", encoding='utf-8') as file:
283
+ json.dump(self.config, file)
284
+
285
+ @staticmethod
286
+ def close_dialog(button_instance):
287
+ # Navigate up the widget tree until we find an MDDialog instance
288
+ current_widget = button_instance
289
+ while current_widget and not isinstance(current_widget, MDDialog):
290
+ current_widget = current_widget.parent
291
+
292
+ # If we found an MDDialog instance, dismiss it
293
+ if isinstance(current_widget, MDDialog):
294
+ current_widget.dismiss()
295
+
296
+ def add_profile(self, profile_name, button_instance):
297
+ # Access the text from the input box
298
+ print(f"New profile name: {profile_name}")
299
+ self.config['profiles'][profile_name] = dict()
300
+ self.set_profile(None, profile_name)
301
+
302
+ # Close the dialog using the button instance
303
+ self.close_dialog(button_instance)
304
+
305
+ def create_new_profile(self):
306
+ self.close_dropdown()
307
+
308
+ profile_input = MDTextField(
309
+ hint_text="Enter profile name",
310
+ size_hint_x=0.8,
311
+ pos_hint={"center_x": 0.5}
312
+ )
313
+
314
+ dialog = MDDialog(
315
+ title="New Profile",
316
+ type="custom",
317
+ content_cls=profile_input,
318
+ buttons=[
319
+ MDRaisedButton(
320
+ text="CANCEL",
321
+ on_release=self.close_dialog
322
+ ),
323
+ MDRaisedButton(
324
+ text="OK",
325
+ on_release=lambda x: self.add_profile(profile_input.text, x)
326
+ )
327
+ ],
328
+ )
329
+ dialog.open()
330
+
331
+ def close_dropdown(self):
332
+ self.profile_dropdown.dismiss()
333
+
334
+ def handle_dropdown(self, profile):
335
+ self.set_profile(None, profile)
336
+ self.close_dropdown()
337
+
338
+ def generate_profile_selector(self):
339
+ profile_dropdown = self.profile_dropdown
340
+ profile_selector = self.root.ids['profile_selector']
341
+ try:
342
+ profiles = list(self.config['profiles'].keys())
343
+ except KeyError:
344
+ profiles = ['DEFAULT']
345
+ max_length = 7
346
+
347
+ profile_dropdown.clear_widgets()
348
+
349
+ btn = MDRaisedButton()
350
+ btn.text = '- New -'
351
+ btn.size_hint_y = None
352
+ btn.bind(on_release=lambda x: self.create_new_profile())
353
+ profile_dropdown.add_widget(btn)
354
+
355
+ for profile in profiles:
356
+ if len(profile) > max_length:
357
+ max_length = len(profile)
358
+
359
+ if profile != profile_selector.text.strip():
360
+ btn = MDRaisedButton()
361
+ btn.text = profile
362
+ btn.size_hint_y = None
363
+ btn.bind(on_release=lambda x, p=profile: self.handle_dropdown(p))
364
+ profile_dropdown.add_widget(btn)
365
+
366
+ number_of_whitespaces = max_length - len(profile_selector.text.strip())
367
+ profile_selector.text = profile_selector.text + (number_of_whitespaces + 1) * " "
368
+
369
+ def set_profile(self, dt=None, profile=None):
370
+ profile_selector = self.root.ids['profile_selector']
371
+
372
+ if not self.config or 'profiles' not in self.config or not self.config['profiles']:
373
+ self.config = self.config or {}
374
+ self.config['profiles'] = {'DEFAULT': {}}
375
+
376
+ if profile is None:
377
+ profile_name = list(self.config['profiles'].keys())[0]
378
+ else:
379
+ profile_name = profile
380
+
381
+ profile_selector.text = profile_name
382
+ self.load_inputs()
383
+ self.generate_profile_selector()
384
+
385
+ def load_inputs(self):
386
+ profile_selector = self.root.ids['profile_selector']
387
+ profile_name = profile_selector.text.strip()
388
+
389
+ profile_values = {}
390
+ if self.config and profile_name in self.config.get('profiles', {}):
391
+ profile_values = self.config['profiles'][profile_name] or {}
392
+
393
+ def sort_key(item):
394
+ key = item[0]
395
+ try:
396
+ return int(str(key).rsplit("_", 1)[-1])
397
+ except ValueError:
398
+ return 0
399
+
400
+ filled = []
401
+ for _name, value in sorted(profile_values.items(), key=sort_key):
402
+ if isinstance(value, (list, tuple)) and len(value) >= 2:
403
+ text, secret_state = value[0], value[1]
404
+ else:
405
+ text, secret_state = value, False
406
+ if text:
407
+ filled.append((text, bool(secret_state)))
408
+
409
+ self.clear_slots()
410
+ self._updating_slots = True
411
+ try:
412
+ for text, secret_state in filled:
413
+ self.add_slot(text=text, secret=secret_state)
414
+ finally:
415
+ self._updating_slots = False
416
+ self.ensure_trailing_empty_slot()
417
+ self.update_window_height()
418
+
419
+ def save_inputs(self):
420
+ profile_selector = self.root.ids['profile_selector']
421
+ profile_name = profile_selector.text.strip()
422
+
423
+ input_fields = {}
424
+ for slot in self._ordered_slots():
425
+ text_input = self._slot_text_input(slot)
426
+ if text_input is None or not text_input.text:
427
+ continue
428
+ secret_cb = self._slot_secret_checkbox(slot)
429
+ secret_state = bool(secret_cb.active) if secret_cb is not None else False
430
+ input_fields[slot.text_input_id] = (text_input.text, secret_state)
431
+
432
+ if not self.config:
433
+ self.config = {'profiles': {}}
434
+ if 'profiles' not in self.config:
435
+ self.config['profiles'] = {}
436
+ self.config['profiles'][profile_name] = input_fields
437
+ self.write_config()
438
+
439
+ def type_text(self, input_text, _checkbox):
440
+ if not input_text:
441
+ # print("No text found")
442
+ return
443
+
444
+ if _checkbox.state == "down":
445
+ end_line = True
446
+ else:
447
+ end_line = False
448
+
449
+ start_delay = float(self.root.ids["start_delay"].value)
450
+ mod_delay = float(self.root.ids["mod_delay"].value)
451
+ type_string_with_delay(
452
+ input_text,
453
+ start_delay=start_delay,
454
+ mod_delay=mod_delay,
455
+ keypress_delay=0.02,
456
+ layout=self.layout,
457
+ end_line=end_line,
458
+ on_error=self.show_type_error,
459
+ )
460
+
461
+ def show_type_error(self, error):
462
+ MDDialog(title="Kan ikke sende tastaturinput", text=str(error)).open()
463
+
464
+ @staticmethod
465
+ def copy_text(input_text, _checkbox):
466
+ if not input_text:
467
+ # print("No text found")
468
+ return
469
+
470
+ cmd = 'echo ' + input_text.strip() + '|clip'
471
+ subprocess.check_call(cmd, shell=True)
472
+
473
+ @staticmethod
474
+ def hide_text(_input_text, _checkbox):
475
+ if _checkbox.state == "down":
476
+ _input_text.password = True
477
+ else:
478
+ _input_text.password = False
479
+
480
+ def set_layout(self, layout):
481
+ self.layout = layout
482
+
483
+ def update_slider_label(self, value):
484
+ rounded_value = round(value, 1)
485
+ self.root.ids["mod_delay"].value = rounded_value
486
+
487
+ def detect_keyboard_layout(self):
488
+ layout_code = get_keyboard_layout()
489
+
490
+ if bool(re.match(r'da|dk', layout_code, re.I)):
491
+ self.layout = 'DA_DK'
492
+ else: # Default to English (US) layout
493
+ self.layout = 'EN_US'
494
+
495
+
496
+ def main():
497
+ ensure_user_desktop_entry()
498
+ autoupdate()
499
+ KeyboardPasterApp().run()
500
+
501
+
502
+ if __name__ == "__main__":
503
+ main()
@@ -0,0 +1,20 @@
1
+ import os
2
+
3
+ from keyboardpaster.shared_resources import APP_WM_CLASS, app_icon_path
4
+
5
+
6
+ def configure_kivy_environment() -> None:
7
+ if getattr(configure_kivy_environment, "_done", False):
8
+ return
9
+
10
+ os.environ["SDL_MOUSE_FOCUS_CLICKTHROUGH"] = "1"
11
+ os.environ.setdefault("SDL_VIDEO_X11_WMCLASS", APP_WM_CLASS)
12
+ os.environ.setdefault("SDL_VIDEO_WAYLAND_WMCLASS", APP_WM_CLASS)
13
+
14
+ icon = app_icon_path()
15
+ if icon is not None:
16
+ from kivy.config import Config
17
+
18
+ Config.set("kivy", "window_icon", str(icon))
19
+
20
+ configure_kivy_environment._done = True # type: ignore[attr-defined]
@@ -0,0 +1,96 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from keyboardpaster.shared_resources import APP_WM_CLASS, app_icon_path
7
+
8
+
9
+ def _desktop_exec_command() -> str:
10
+ project_root = Path(__file__).resolve().parent.parent
11
+ main_py = project_root / "main.py"
12
+ if main_py.is_file() and (project_root / "setup.py").is_file():
13
+ return f"{sys.executable} {main_py}"
14
+
15
+ entry_point = shutil.which("keyboardpaster")
16
+ if entry_point:
17
+ return entry_point
18
+
19
+ return sys.executable
20
+
21
+
22
+ def _remove_broken_hicolor_override() -> None:
23
+ """Remove incomplete user hicolor index that breaks other apps' icons."""
24
+ icon_theme_root = Path.home() / ".local/share/icons/hicolor"
25
+ index_file = icon_theme_root / "index.theme"
26
+ if not index_file.is_file():
27
+ return
28
+
29
+ try:
30
+ text = index_file.read_text(encoding="utf-8")
31
+ except OSError:
32
+ return
33
+
34
+ # Only remove the incomplete theme we previously generated.
35
+ if "Directories=256x256/apps" not in text:
36
+ return
37
+ if "16x16/apps" in text or "scalable/apps" in text:
38
+ return
39
+
40
+ try:
41
+ index_file.unlink()
42
+ except OSError:
43
+ pass
44
+
45
+ cache_file = icon_theme_root / "icon-theme.cache"
46
+ try:
47
+ cache_file.unlink()
48
+ except OSError:
49
+ pass
50
+
51
+
52
+ def ensure_user_desktop_entry() -> None:
53
+ if sys.platform != "linux":
54
+ return
55
+
56
+ icon = app_icon_path()
57
+ if icon is None:
58
+ return
59
+
60
+ _remove_broken_hicolor_override()
61
+
62
+ apps_dir = Path.home() / ".local/share/applications"
63
+ data_dir = Path.home() / ".local/share/keyboardpaster"
64
+ apps_dir.mkdir(parents=True, exist_ok=True)
65
+ data_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ # Keep our icon outside the hicolor theme tree so we never break system icons.
68
+ installed_icon = data_dir / "keyboardpaster.png"
69
+ if not installed_icon.exists() or icon.read_bytes() != installed_icon.read_bytes():
70
+ shutil.copy2(icon, installed_icon)
71
+
72
+ desktop_path = apps_dir / f"{APP_WM_CLASS}.desktop"
73
+ desktop_text = "\n".join(
74
+ [
75
+ "[Desktop Entry]",
76
+ "Version=1.0",
77
+ "Type=Application",
78
+ "Name=Keyboard Paster",
79
+ "Comment=Type text using simulated keyboard input",
80
+ f"Exec={_desktop_exec_command()}",
81
+ f"Icon={installed_icon}",
82
+ "Terminal=false",
83
+ "Categories=Utility;",
84
+ f"StartupWMClass={APP_WM_CLASS}",
85
+ "",
86
+ ]
87
+ )
88
+ existing = desktop_path.read_text(encoding="utf-8") if desktop_path.exists() else ""
89
+ if existing != desktop_text:
90
+ desktop_path.write_text(desktop_text, encoding="utf-8")
91
+ if shutil.which("update-desktop-database"):
92
+ subprocess.run(
93
+ ["update-desktop-database", str(apps_dir)],
94
+ check=False,
95
+ capture_output=True,
96
+ )