-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsettings_gui.py
More file actions
2420 lines (2240 loc) · 107 KB
/
Copy pathsettings_gui.py
File metadata and controls
2420 lines (2240 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""GTK + libhandy preferences window for LinuxPop.
Uses Hdy.PreferencesWindow with grouped action rows instead of the legacy
Gtk.Dialog + grid layout - gives a modern GNOME-Settings-style boxed-list UI
without migrating to GTK4.
Apply-on-change semantics: edits save immediately, no Save/Cancel buttons.
"""
from __future__ import annotations
import shlex
import shutil
import subprocess
import threading
from typing import Callable
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("Handy", "1")
from gi.repository import Gdk, GLib, Gtk, Handy, Pango # noqa: E402
# Some PyGObject builds don't expose Gdk.X11 as a top-level submodule -
# attempt to require it so Gdk.X11.get_server_time is available.
try:
gi.require_version("GdkX11", "3.0")
from gi.repository import GdkX11 # noqa: F401, E402
except (ImportError, ValueError):
pass
from settings import get_settings
Handy.init()
def _unwrap_subtitle_labels(root: Gtk.Widget) -> None:
"""Walk every label under `root` and let any HdyActionRow / HdyPreferencesRow
subtitle wrap onto multiple lines instead of ellipsising.
libhandy renders the subtitle as a Gtk.Label with style class ``subtitle``
and ``ellipsize=END``, ``lines=1`` baked in - there's no public API to
flip that off per row. The only way to keep the descriptions readable
in a narrow dialog is to walk the realised widget tree after show_all()
and patch each subtitle label directly.
Also re-runs once on idle: HdyPreferencesGroup re-creates / re-styles
rows during its own first allocation pass, which would replace the
labels we just patched.
"""
def visit(widget: Gtk.Widget) -> None:
if isinstance(widget, Gtk.Label):
try:
ctx = widget.get_style_context()
if ctx.has_class("subtitle"):
widget.set_ellipsize(Pango.EllipsizeMode.NONE)
widget.set_line_wrap(True)
widget.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
widget.set_max_width_chars(-1)
widget.set_xalign(0)
# Allow Pango to use as many lines as it needs.
try:
widget.set_lines(-1)
except AttributeError:
pass
except Exception:
pass
if isinstance(widget, Gtk.Container):
children: list[Gtk.Widget] = []
try:
widget.forall(lambda c, _: children.append(c), None)
except Exception:
children = widget.get_children()
for c in children:
visit(c)
visit(root)
def _again() -> bool:
visit(root)
return False
GLib.idle_add(_again)
GLib.timeout_add(150, _again)
def _open_text_editor_modal(
parent: Gtk.Window | None,
title: str,
subtitle: str,
initial_text: str,
placeholder_text: str,
) -> str | None:
"""Pop a modal editor with a big multi-line TextView, Save / Cancel.
Returns the new text on Save, None on Cancel. Used for blocklists,
snippet variables - things that need a whole textarea but shouldn't
occupy permanent space in the Settings page when empty."""
dlg = Gtk.Dialog(title=title, transient_for=parent, flags=0)
dlg.set_default_size(580, 420)
dlg.set_icon_name("linuxpop")
dlg.add_buttons("Cancel", Gtk.ResponseType.CANCEL,
"Save", Gtk.ResponseType.OK)
dlg.set_default_response(Gtk.ResponseType.OK)
content = dlg.get_content_area()
content.set_spacing(8)
content.set_margin_top(12)
content.set_margin_bottom(12)
content.set_margin_start(14)
content.set_margin_end(14)
sub_lbl = Gtk.Label(xalign=0)
sub_lbl.set_line_wrap(True)
sub_lbl.set_markup(f"<small>{GLib.markup_escape_text(subtitle)}</small>")
sub_lbl.get_style_context().add_class("dim-label")
content.add(sub_lbl)
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_shadow_type(Gtk.ShadowType.IN)
scroll.set_hexpand(True)
scroll.set_vexpand(True)
view = Gtk.TextView()
view.set_wrap_mode(Gtk.WrapMode.NONE)
try:
view.set_monospace(True)
except AttributeError:
pass
view.get_style_context().add_class("lp-cmd-edit")
if initial_text:
view.get_buffer().set_text(initial_text)
_attach_textarea_placeholder(view, placeholder_text)
scroll.add(view)
content.pack_start(scroll, True, True, 0)
dlg.show_all()
view.grab_focus()
response = dlg.run()
buf = view.get_buffer()
s, e = buf.get_bounds()
raw = buf.get_text(s, e, True)
is_placeholder = getattr(view, "_placeholder_active", False)
dlg.destroy()
if response != Gtk.ResponseType.OK:
return None
return "" if is_placeholder else raw
def _attach_textarea_placeholder(view: Gtk.TextView, placeholder_text: str) -> None:
"""Give a Gtk.TextView the placeholder-text behaviour that Gtk.Entry
has built-in. When the buffer is empty the placeholder shows in
grey italics; focus or any typing wipes it. Empty state is restored
on focus-out so the user always sees the hint when the field is blank.
Save handlers MUST check view._placeholder_active and treat the
placeholder text as "no value" - otherwise the placeholder string
would be persisted as data.
"""
buf = view.get_buffer()
# Per-view tag so callers can have multiple placeholders without
# name collisions in the buffer's tag table.
tag = buf.create_tag(
f"lp-placeholder-{id(view)}",
foreground="#7a8090",
style=Pango.Style.ITALIC,
)
view._placeholder_active = False
def _set_placeholder() -> None:
# Suppress the 'changed' signal during the placeholder swap so
# debounced save handlers don't fire on what is really UI chrome.
view._setting_placeholder = True
buf.set_text(placeholder_text)
s, e = buf.get_bounds()
buf.apply_tag(tag, s, e)
view._placeholder_active = True
view._setting_placeholder = False
s, e = buf.get_bounds()
if not buf.get_text(s, e, True):
_set_placeholder()
def _clear_placeholder() -> None:
if view._placeholder_active:
view._setting_placeholder = True
buf.set_text("")
view._placeholder_active = False
view._setting_placeholder = False
def _on_key_press(_w, event):
# Clear the hint the moment the user types something that produces
# text (letters, digits, space, Ctrl+V...), but keep it for pure
# navigation/modifier keys - and, crucially, while the field is
# merely focused. Clearing on focus-in used to wipe the hint the
# instant the dialog opened (the TextView grabs focus on show), so
# the example only reappeared after a focus-out/in cycle such as a
# right-click. keyval_to_unicode is non-zero exactly for the keys
# that put a character in the buffer.
if view._placeholder_active and Gdk.keyval_to_unicode(event.keyval):
_clear_placeholder()
return False
def _on_focus_out(_w, _e):
s, e = buf.get_bounds()
if not buf.get_text(s, e, True).strip():
_set_placeholder()
return False
view.connect("key-press-event", _on_key_press)
view.connect("focus-out-event", _on_focus_out)
def _force_to_front(window: Gtk.Window) -> None:
"""Raise the window to the foreground on X11 even when WM focus-
stealing prevention or another LinuxPop dialog's keep_above would
otherwise rank it lower in the stack."""
try:
window.deiconify()
gdk_win = window.get_window()
if gdk_win is not None:
try:
ts = Gdk.X11.get_server_time(gdk_win)
except Exception:
ts = Gtk.get_current_event_time() or 0
window.present_with_time(ts)
# Explicit X11 raise bypasses focus arbitration -- needed
# because the clipboard picker holds permanent keep_above
# and would otherwise float over us.
try:
gdk_win.raise_()
except Exception:
pass
else:
window.present()
# Quick keep-above toggle nudges most WMs into raising it.
window.set_keep_above(True)
GLib.timeout_add(150, lambda: (window.set_keep_above(False), False)[1])
window.present()
except Exception:
try:
window.present()
except Exception:
pass
_MODIFIER_KEYVALS = {
Gdk.KEY_Control_L, Gdk.KEY_Control_R,
Gdk.KEY_Shift_L, Gdk.KEY_Shift_R,
Gdk.KEY_Alt_L, Gdk.KEY_Alt_R,
Gdk.KEY_Super_L, Gdk.KEY_Super_R,
Gdk.KEY_Meta_L, Gdk.KEY_Meta_R,
Gdk.KEY_Hyper_L, Gdk.KEY_Hyper_R,
}
def _detect_userscript_manager() -> tuple[str, str] | None:
"""Look for a Tampermonkey / Violentmonkey / Greasemonkey install
in any of the common Firefox / Chrome family browser profiles on
Linux. Returns (manager_name, browser_label) or None when nothing
is found. We can't poke the browser sandbox directly, but extension
IDs in the profile-on-disk are stable across versions."""
import json
from pathlib import Path
home = Path.home()
# Firefox-family profile roots. The .config/ path is what Mint's
# system Firefox uses; .mozilla/ is the upstream default; the
# snap/flatpak paths cover sandboxed installs; Zen / LibreWolf are
# popular Firefox forks LinuxPop users might have.
firefox_roots = [
(home / ".config" / "mozilla" / "firefox", "Firefox"),
(home / ".mozilla" / "firefox", "Firefox"),
(home / "snap" / "firefox" / "common" / ".mozilla" / "firefox", "Firefox (Snap)"),
(home / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox", "Firefox (Flatpak)"),
(home / ".var" / "app" / "app.zen_browser.zen" / ".zen", "Zen Browser"),
(home / ".librewolf", "LibreWolf"),
]
firefox_ids = {
"{aecec67f-0d10-4fa7-b7c7-609a2db280cf}": "Violentmonkey",
"firefox@tampermonkey.net": "Tampermonkey",
"{e4a8a97b-f2ed-450b-b12d-ee082ba24781}": "Greasemonkey",
}
for root, label in firefox_roots:
if not root.is_dir():
continue
# Each profile dir contains extensions.json. Brute-force scan
# all immediate subdirs - typically 1-2 profiles per user.
for prof in root.iterdir():
ext_json = prof / "extensions.json"
if not ext_json.is_file():
continue
try:
data = json.loads(ext_json.read_text())
except Exception:
continue
for addon in data.get("addons", []):
aid = addon.get("id") or ""
if not addon.get("active", False):
continue
name = firefox_ids.get(aid)
if name:
return name, label
# Chrome family: each extension is a subdirectory whose name is
# the extension ID. We don't need to parse anything, just check
# that the directory exists.
chrome_roots = [
(home / ".config" / "google-chrome", "Chrome"),
(home / ".config" / "chromium", "Chromium"),
(home / ".config" / "BraveSoftware" / "Brave-Browser", "Brave"),
(home / ".config" / "microsoft-edge", "Edge"),
(home / ".config" / "vivaldi", "Vivaldi"),
(home / ".config" / "opera", "Opera"),
]
chrome_ids = {
"dhdgffkkebhmkfjojejmpbldmpobfkfo": "Tampermonkey",
"jinjaccalgkegednnccohejagnlnfdag": "Violentmonkey",
# Tampermonkey on Edge uses the same Chromium ID generally,
# but its store ID differs - cover both.
"iikmkjmpaadaobahmlepeloendndfphd": "Tampermonkey",
}
for root, label in chrome_roots:
if not root.is_dir():
continue
# Profiles: "Default", "Profile 1", "Profile 2", ...
for profile in root.iterdir():
ext_root = profile / "Extensions"
if not ext_root.is_dir():
continue
for ext_dir in ext_root.iterdir():
name = chrome_ids.get(ext_dir.name)
if name and ext_dir.is_dir():
return name, label
return None
def _detect_default_browser_family() -> str | None:
"""Return a coarse browser family name ('firefox', 'chrome', 'edge',
'brave', 'opera', 'vivaldi', 'chromium') so the userscript-manager
install button can deep-link to the right add-on store. Returns
None when we can't tell - the caller falls back to a two-button
chooser."""
try:
out = subprocess.run(
["xdg-mime", "query", "default", "x-scheme-handler/https"],
capture_output=True, text=True, timeout=2.0, check=False,
)
desktop = (out.stdout or "").strip().lower()
except (OSError, subprocess.SubprocessError):
return None
# Order matters: 'chromium' must check before generic 'chrome' since
# the chromium desktop file often contains both substrings.
table = [
("firefox", "firefox"),
("librewolf", "firefox"),
("tor-browser", "firefox"),
("zen", "firefox"),
("brave", "brave"),
("vivaldi", "vivaldi"),
("opera", "opera"),
("microsoft-edge", "edge"),
("edge", "edge"),
("chromium", "chromium"),
("google-chrome", "chrome"),
("chrome", "chrome"),
]
for needle, family in table:
if needle in desktop:
return family
return None
def _format_combo(keyval: int, state: int) -> str:
parts = []
if state & Gdk.ModifierType.CONTROL_MASK:
parts.append("ctrl")
if state & Gdk.ModifierType.MOD1_MASK:
parts.append("alt")
if state & Gdk.ModifierType.SHIFT_MASK:
parts.append("shift")
if state & Gdk.ModifierType.MOD4_MASK:
parts.append("super")
name = Gdk.keyval_name(keyval) or ""
parts.append(name.lower())
return "+".join(parts)
class HotkeyRecorder(Gtk.Button):
"""Compact button that captures a keypress combo when clicked."""
def __init__(self, initial: str = "", on_changed: Callable[[str], None] | None = None) -> None:
super().__init__()
self._value = initial
self._recording = False
self._on_changed = on_changed
self.set_valign(Gtk.Align.CENTER)
self._update_label()
self.connect("clicked", self._on_clicked)
self.connect("key-press-event", self._on_key_press)
self.set_can_focus(True)
def get_value(self) -> str:
return self._value
def set_value(self, value: str) -> None:
if self._value == value:
return
self._value = value
self._recording = False
self._update_label()
if self._on_changed:
self._on_changed(value)
def _update_label(self) -> None:
if self._recording:
self.set_label("⏺ Press a hotkey (Esc to cancel)")
elif self._value:
self.set_label(self._value)
else:
self.set_label("Click to record…")
def _on_clicked(self, *_):
self._recording = True
self._update_label()
self.grab_focus()
def _on_key_press(self, _widget, event):
if not self._recording:
return False
plain_esc = (
event.keyval == Gdk.KEY_Escape
and not (event.state & (
Gdk.ModifierType.CONTROL_MASK
| Gdk.ModifierType.MOD1_MASK
| Gdk.ModifierType.MOD4_MASK
))
)
if plain_esc:
self._recording = False
self._update_label()
return True
if event.keyval in _MODIFIER_KEYVALS:
return True
self.set_value(_format_combo(event.keyval, event.state))
return True
#: Per-row leading icon for the Settings rows, so each gets a coloured tile in
#: the same style as the Plugin Manager. Titles must match set_title() exactly;
#: anything unmapped falls back to a generic gear (never a broken glyph).
_SETTING_ICONS = {
"Theme": "applications-graphics-symbolic",
"Popup button size": "view-fullscreen-symbolic",
"Maximum buttons in the popup": "view-grid-symbolic",
"When actions overflow": "view-more-symbolic",
"Popup hotkey": "preferences-desktop-keyboard-shortcuts-symbolic",
"Screen OCR hotkey": "camera-photo-symbolic",
"Auto-popup on selection": "edit-select-all-symbolic",
"Modifier+double-click for the edit menu": "input-mouse-symbolic",
"Modifier key": "input-keyboard-symbolic",
"Force all plugins modifier": "view-grid-symbolic",
"Start at login": "system-run-symbolic",
"Clipboard history": "edit-copy-symbolic",
"Optional clipboard shortcut": "edit-paste-symbolic",
"Snippet triggers (text expansion)": "document-edit-symbolic",
"Don't expand triggers in these apps or sites": "security-high-symbolic",
"Snippet variables": "view-list-symbolic",
"Shell expansion in snippets": "utilities-terminal-symbolic",
"Skip short auto-popup selections": "format-justify-left-symbolic",
"Don't show in these apps or pages": "security-medium-symbolic",
"How to send the text": "mail-send-symbolic",
"Browser bridge": "network-server-symbolic",
"Per-service method (advanced)": "applications-system-symbolic",
"Auto-submit after paste": "go-next-symbolic",
"Search engine": "edit-find-symbolic",
"Custom search URL": "insert-link-symbolic",
"Trigger hotkey on first press": "media-playback-start-symbolic",
"MCP server (advanced)": "network-server-symbolic",
"Reset settings to defaults": "view-refresh-symbolic",
"Keep terminal open after running": "utilities-terminal-symbolic",
"Need a userscript manager?": "help-about-symbolic",
}
class SettingsDialog:
def __init__(self, on_changed: Callable[[], None] | None = None) -> None:
self._on_changed = on_changed
self._settings = get_settings()
self._window: Handy.PreferencesWindow | None = None
def show(self) -> None:
if self._window is not None and self._window.get_visible():
_force_to_front(self._window)
return
win = Handy.PreferencesWindow()
win.set_title("LinuxPop")
win.set_search_enabled(False)
win.set_default_size(720, 640)
win.set_position(Gtk.WindowPosition.CENTER)
win.set_icon_name("linuxpop")
win.set_modal(False)
win.connect("destroy", self._on_destroy)
page = Handy.PreferencesPage()
page.set_title("General")
page.set_icon_name("preferences-system-symbolic")
# Snippets & Clipboard first - that's the core of what people use
# LinuxPop for. Activation comes second because users new to
# the app reach for "what's my hotkey" before anything else.
# AI services sits at position 3 because that's the most-edited
# section after the engagement curve picks up - burying it past
# 8 groups (the historic order) hurt discoverability.
page.add(self._build_snippets_clipboard_group())
page.add(self._build_activation_group())
for ai_group in self._build_ai_groups():
page.add(ai_group)
page.add(self._build_appearance_group())
page.add(self._build_timing_group())
page.add(self._build_filter_group())
page.add(self._build_search_group())
page.add(self._build_terminal_group())
page.add(self._build_advanced_group())
# Donation entry-points live in the tray menu, the About dialog
# and the first-run welcome - see welcome.open_support_picker.
# Settings stays focused on configuration.
win.add(page)
self._decorate_badges(page)
win.show_all()
# Subtitle wrap must be patched AFTER show_all() so the realised
# label widgets exist to flip.
_unwrap_subtitle_labels(win)
self._window = win
_force_to_front(win)
def _on_destroy(self, *_):
self._window = None
# ---- coloured icon badges (matches the Plugin Manager) -------------------
def _badge(self, icon_name: str | None, color_index: int) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.set_size_request(32, 32)
box.set_valign(Gtk.Align.CENTER)
box.set_halign(Gtk.Align.CENTER)
box.set_margin_end(8)
ctx = box.get_style_context()
ctx.add_class("lp-badge")
ctx.add_class(f"lp-badge-{color_index % 4}")
it = Gtk.IconTheme.get_default()
name = icon_name if (icon_name and it.has_icon(icon_name)) \
else "emblem-system-symbolic"
img = Gtk.Image.new_from_icon_name(name, Gtk.IconSize.BUTTON)
img.set_pixel_size(17)
img.get_style_context().add_class("lp-badge-glyph")
box.pack_start(img, True, True, 0)
box.show_all()
return box
def _decorate_badges(self, root: Gtk.Widget) -> None:
"""Give each settings row a coloured icon tile so Settings reads as the
same family as the Plugin Manager. Walks the realised row tree, skipping
the inside of each row (so expander sub-rows aren't decorated)."""
rows: list[Gtk.Widget] = []
def walk(w):
if isinstance(w, (Handy.ActionRow, Handy.ExpanderRow)):
rows.append(w)
return
if isinstance(w, Gtk.Container):
w.forall(lambda c, *_a: walk(c), None)
walk(root)
for i, r in enumerate(rows):
icon = _SETTING_ICONS.get(r.get_title() or "")
try:
r.add_prefix(self._badge(icon, i))
except Exception:
pass
# ---- groups --------------------------------------------------------------
def _build_appearance_group(self) -> Handy.PreferencesGroup:
group = Handy.PreferencesGroup()
group.set_title("Appearance")
theme_row = Handy.ActionRow()
theme_row.set_title("Theme")
theme_row.set_subtitle(
"Dark uses the cobalt + violet palette. Light gives you the "
"same layout on a clean off-white. 'Follow system' picks one "
"based on your desktop theme.")
theme_combo = Gtk.ComboBoxText()
theme_combo.set_valign(Gtk.Align.CENTER)
theme_options = [
("dark", "Dark"),
("light", "Light"),
("system", "Follow system"),
]
for key, label in theme_options:
theme_combo.append(key, label)
current = self._settings.get("theme", "dark") or "dark"
theme_combo.set_active_id(current)
def _on_theme_changed(_combo):
mode = theme_combo.get_active_id() or "dark"
self._save_key("theme", mode)
try:
import theme as _theme
_theme.install_premium_theme(mode)
except Exception as exc:
print(f"[settings] theme reload failed: {exc}")
# Popup uses its own CSS provider; rebuild it so the
# selection popup picks up the new palette too.
try:
import popup as _popup
_popup.reinstall_popup_css()
except Exception as exc:
print(f"[settings] popup theme reload failed: {exc}")
theme_combo.connect("changed", _on_theme_changed)
theme_row.add(theme_combo)
theme_row.set_activatable_widget(theme_combo)
group.add(theme_row)
# Popup button size: how big each action chip in the floating
# popup is. Clamped to [16, 32] pixels - under 16 the symbolic
# icons go muddy at every common Linux resolution; over 32 is
# touch-target territory mouse users don't benefit from, and it
# turns the popup into a screen-spanning bar.
size_row = Handy.ActionRow()
size_row.set_title("Popup button size")
size_row.set_subtitle(
"How big each action button in the popup is, in pixels. "
"16 is small and dense; 32 is roomy and easy to click. "
"Tip: smaller buttons also fit more actions on a single row "
"before the popup has to wrap or expand (see ‘When actions "
"overflow’ below).")
size_adj = Gtk.Adjustment(
value=int(self._settings.get("popup_button_size", 22) or 22),
lower=16, upper=32, step_increment=1, page_increment=4,
)
size_spin = Gtk.SpinButton()
size_spin.set_valign(Gtk.Align.CENTER)
size_spin.set_adjustment(size_adj)
size_spin.set_numeric(True)
def _on_size_changed(spin: Gtk.SpinButton) -> None:
self._save_key("popup_button_size", int(spin.get_value()))
# Live-apply: rebuild the popup's CSS provider so the next
# show_for() renders at the new size.
try:
import popup as _popup
_popup.reinstall_popup_css()
except Exception as exc:
print(f"[settings] popup css reload failed: {exc}")
size_spin.connect("value-changed", _on_size_changed)
size_row.add(size_spin)
size_row.set_activatable_widget(size_spin)
group.add(size_row)
# ----- Plugin icon style: colourful tiles vs uniform mono glyphs -----
icon_row = Handy.ActionRow()
icon_row.set_title("Plugin icon style")
icon_row.set_subtitle(
"Colourful tiles, or uniform mono glyphs that match the plain "
"edit icons. Applies to the popup and Plugin Manager.")
icon_combo = Gtk.ComboBoxText()
icon_combo.append("color", "Colourful tiles")
icon_combo.append("glyph", "Simple glyphs")
_cur_style = (self._settings.get("icon_style") or "color")
if _cur_style not in ("color", "glyph"):
_cur_style = "color"
icon_combo.set_active_id(_cur_style)
icon_combo.set_valign(Gtk.Align.CENTER)
icon_row.add(icon_combo)
icon_row.set_activatable_widget(icon_combo)
group.add(icon_row)
# Live preview strip: sample icons rendered in the chosen style.
icon_preview = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
spacing=12)
icon_preview.set_halign(Gtk.Align.CENTER)
icon_preview.set_margin_top(8)
icon_preview.set_margin_bottom(12)
def _rebuild_icon_preview(style: str) -> None:
for ch in icon_preview.get_children():
ch.destroy()
try:
import icon_style as _ist
concepts = _ist.PREVIEW_CONCEPTS
except Exception:
concepts = []
for concept in concepts:
nm = (f"linuxpop-{concept}-symbolic" if style == "glyph"
else f"linuxpop-{concept}")
im = Gtk.Image.new_from_icon_name(nm, Gtk.IconSize.DND)
im.set_pixel_size(30)
icon_preview.pack_start(im, False, False, 0)
icon_preview.show_all()
_rebuild_icon_preview(_cur_style)
group.add(icon_preview)
def _on_icon_style_changed(combo: Gtk.ComboBoxText) -> None:
style = combo.get_active_id() or "color"
self._save_key("icon_style", style)
_rebuild_icon_preview(style)
icon_combo.connect("changed", _on_icon_style_changed)
# ----- Tray icon: coloured badge vs mono glyph for light/dark panels -----
tray_row = Handy.ActionRow()
tray_row.set_title("Tray icon")
tray_row.set_subtitle(
"The colour badge is visible on any panel. The mono styles let you "
"match your panel manually (KDE won't recolour it automatically): "
"Light for a dark panel, Dark for a light panel.")
tray_combo = Gtk.ComboBoxText()
tray_combo.append("color", "Colour (any panel)")
tray_combo.append("light", "Light mono (dark panels)")
tray_combo.append("dark", "Dark mono (light panels)")
_cur_tray = (self._settings.get("tray_icon_style") or "color")
if _cur_tray not in ("color", "light", "dark"):
_cur_tray = "color"
tray_combo.set_active_id(_cur_tray)
tray_combo.set_valign(Gtk.Align.CENTER)
tray_row.add(tray_combo)
tray_row.set_activatable_widget(tray_combo)
group.add(tray_row)
def _on_tray_icon_changed(combo: Gtk.ComboBoxText) -> None:
# _save_key fires the on_changed chain -> main.py tells the tray
# subprocess to re-render its icon live (no restart needed).
self._save_key("tray_icon_style", combo.get_active_id() or "color")
tray_combo.connect("changed", _on_tray_icon_changed)
# Max buttons per popup: how many actions can show before the
# popup wraps to a second row and (past 2 rows) drops to a
# "+N" overflow chip. Pairs with popup_button_size to govern
# popup density end-to-end. Range starts at 4 (anything less
# makes the popup feel broken) and tops out at 40 (point of
# diminishing returns; the chip handles the rest).
count_row = Handy.ActionRow()
count_row.set_title("Maximum buttons in the popup")
count_row.set_subtitle(
"Cap on how many actions the popup shows. Extras wrap to "
"a second row first; beyond that, a '+N' chip lets you open "
"Plugin Manager to reorder. Raise this if you have lots of "
"plugins enabled and want them all visible.")
count_adj = Gtk.Adjustment(
value=int(self._settings.get("max_popup_buttons", 24) or 24),
lower=4, upper=40, step_increment=1, page_increment=4,
)
count_spin = Gtk.SpinButton()
count_spin.set_valign(Gtk.Align.CENTER)
count_spin.set_adjustment(count_adj)
count_spin.set_numeric(True)
count_spin.connect(
"value-changed",
lambda spin: self._save_key("max_popup_buttons", int(spin.get_value())))
count_row.add(count_spin)
count_row.set_activatable_widget(count_spin)
group.add(count_row)
# Overflow mode: what the popup does when more actions match than fit
# on a single line. Bounded by the cap above.
ov_row = Handy.ActionRow()
ov_row.set_title("When actions overflow")
ov_row.set_subtitle(
"How the popup handles more actions than fit on one line. "
"‘One row + expand arrow’ stays compact and reveals the rest on "
"click; ‘Wrap’ uses two rows; ‘One row only’ hides extras behind "
"a +N chip.")
ov_combo = Gtk.ComboBoxText()
ov_combo.set_valign(Gtk.Align.CENTER)
for _key, _label in (
("expand", "One row + expand arrow"),
("wrap", "Wrap to two rows"),
("cap", "One row only (+N chip)"),
):
ov_combo.append(_key, _label)
ov_combo.set_active_id(
(self._settings.get("popup_overflow_mode") or "expand"))
ov_combo.connect(
"changed",
lambda c: self._save_key(
"popup_overflow_mode", c.get_active_id() or "expand"))
ov_row.add(ov_combo)
ov_row.set_activatable_widget(ov_combo)
group.add(ov_row)
return group
def _build_activation_group(self) -> Handy.PreferencesGroup:
group = Handy.PreferencesGroup()
group.set_title("Activation")
group.set_description("How LinuxPop appears.")
# Popup hotkey row. Lives here rather than in a dedicated Hotkeys
# group because, with the clipboard shortcut moved to Snippets &
# Clipboard, there's only one keyboard shortcut left and it
# belongs alongside the other "how do I summon the popup" toggles.
hk_row = Handy.ActionRow()
hk_row.set_title("Popup hotkey")
hk_row.set_subtitle(
"Press this anywhere to open the popup. If you have text "
"highlighted, it shows actions for that text. If not, it "
"shows a paste menu instead.")
recorder = HotkeyRecorder(
self._settings.get("hotkey") or "",
on_changed=lambda v: self._save_key("hotkey", v),
)
hk_row.add(recorder)
clear = Gtk.Button.new_from_icon_name(
"edit-clear-symbolic", Gtk.IconSize.BUTTON)
clear.set_valign(Gtk.Align.CENTER)
clear.set_tooltip_text("Disable hotkey")
clear.connect("clicked", lambda *_: recorder.set_value(""))
hk_row.add(clear)
group.add(hk_row)
# OCR hotkey row - sits next to the popup hotkey so the two
# "summon something with a key chord" rows cluster together.
# The status subtitle changes based on whether the OCR backend
# is reachable (maim + tesseract), so the user knows whether
# the hotkey will actually do anything when pressed.
ocr_row = Handy.ActionRow()
ocr_row.set_title("Screen OCR hotkey")
try:
from screen_ocr import is_supported as _ocr_supported
ocr_ok, ocr_reason = _ocr_supported()
except Exception:
ocr_ok, ocr_reason = False, "screen_ocr module not available"
if ocr_ok:
ocr_row.set_subtitle(
"Draw a box anywhere; the text inside is copied as text.")
else:
ocr_row.set_subtitle(f"Setup needed: {ocr_reason}.")
ocr_recorder = HotkeyRecorder(
self._settings.get("ocr_hotkey") or "",
on_changed=lambda v: self._save_key("ocr_hotkey", v),
)
# When OCR backends aren't available, offer a one-click Install
# button that runs the install in the background via pkexec (a
# graphical password prompt), then re-probes and updates the row
# live. Replaces the old "copy command to clipboard" dance, which
# also relied on xclip - absent on a Wayland box.
if not ocr_ok:
try:
from screen_ocr import install_argv as _ocr_install_argv
argv = _ocr_install_argv()
except Exception:
argv = None
ocr_install_btn = Gtk.Button(label="Install")
ocr_install_btn.set_valign(Gtk.Align.CENTER)
ocr_install_btn.get_style_context().add_class("suggested-action")
ocr_install_btn.set_tooltip_text(
"Installs the OCR tools in the background "
"(asks for your password).")
if argv is None:
ocr_install_btn.set_sensitive(False)
ocr_install_btn.set_tooltip_text(
"Couldn't detect your package manager - please "
"install tesseract manually.")
def _on_ocr_install(btn, argv=argv, row=ocr_row):
if not argv:
return
btn.set_sensitive(False)
btn.set_label("Installing...")
row.set_subtitle(
"Installing - authorise the password prompt...")
def _done(ok, err):
try:
from screen_ocr import is_supported as _sup
now_ok, reason = _sup()
except Exception:
now_ok, reason = ok, ""
if now_ok:
row.set_subtitle(
"Ready. Draw a box anywhere to copy its text.")
btn.set_label("Installed")
btn.set_sensitive(False)
try:
btn.get_style_context().remove_class(
"suggested-action")
except Exception:
pass
# Bind the hotkey now that the backend works.
if self._on_changed is not None:
try:
self._on_changed()
except Exception:
pass
else:
row.set_subtitle(
f"Install didn't complete: {reason or err}. "
"Try again.")
btn.set_label("Install")
btn.set_sensitive(True)
return False
def _worker():
ok, err = False, ""
try:
res = subprocess.run(
argv, capture_output=True, timeout=600)
ok = res.returncode == 0
if not ok:
err = (res.stderr or b"").decode(
"utf-8", "replace").strip()[-160:]
except Exception as exc: # noqa: BLE001
err = str(exc)
GLib.idle_add(_done, ok, err)
threading.Thread(target=_worker, daemon=True,
name="ocr-install").start()
ocr_install_btn.connect("clicked", _on_ocr_install)
ocr_row.add(ocr_install_btn)
ocr_row.add(ocr_recorder)
ocr_clear = Gtk.Button.new_from_icon_name(
"edit-clear-symbolic", Gtk.IconSize.BUTTON)
ocr_clear.set_valign(Gtk.Align.CENTER)
ocr_clear.set_tooltip_text("Disable hotkey")
ocr_clear.connect("clicked", lambda *_: ocr_recorder.set_value(""))
ocr_row.add(ocr_clear)
group.add(ocr_row)
# Auto-popup on selection (switch row)
sel_row = Handy.ActionRow()
sel_row.set_title("Auto-popup on selection")
sel_row.set_subtitle(
"Show the popup automatically whenever you highlight text in any app.")
sel_switch = Gtk.Switch()
sel_switch.set_valign(Gtk.Align.CENTER)
sel_switch.set_active(bool(self._settings.get("show_on_selection")))
sel_switch.connect("notify::active", self._on_switch, "show_on_selection")
sel_row.add(sel_switch)
sel_row.set_activatable_widget(sel_switch)
group.add(sel_row)
# Double-click in an empty editable field shows the edit menu
# (Paste / Select all / Backspace). PopClip's text-field gesture.
dbl_row = Handy.ActionRow()
dbl_row.set_title("Modifier+double-click for the edit menu")
dbl_row.set_subtitle(
"Hold the modifier key below and double-click inside any "
"text field to bring up Paste / Select all / Backspace at "
"the cursor. The modifier is required so it never collides "
"with the app's own double-click-to-select-a-word gesture. "
"Requires watching mouse clicks globally (nothing is logged or "
"sent). On Wayland this reaches XWayland windows only - for "
"native Wayland apps, press the popup hotkey instead; it shows "
"the same menu when nothing is selected.")
dbl_switch = Gtk.Switch()
dbl_switch.set_valign(Gtk.Align.CENTER)
dbl_switch.set_active(
bool(self._settings.get("double_click_popup_enabled", False)))
dbl_switch.connect(
"notify::active", self._on_switch, "double_click_popup_enabled")
# Live-apply happens through the settings on_changed callback in
# main.py - no separate plumbing needed here.
dbl_row.add(dbl_switch)
dbl_row.set_activatable_widget(dbl_switch)
group.add(dbl_row)
mod_row = Handy.ActionRow()
mod_row.set_title("Modifier key")
mod_row.set_subtitle(
"Which key must be held during the double-click. "
"Ctrl is the safest default; pick another if Ctrl is "
"already mapped to something you do with the mouse.")
mod_combo = Gtk.ComboBoxText()
mod_combo.set_valign(Gtk.Align.CENTER)
for key, label in [
("ctrl", "Ctrl"),
("shift", "Shift"),
("alt", "Alt"),
("super", "Super (Windows key)"),
]:
mod_combo.append(key, label)
current_mod = (self._settings.get("double_click_modifier") or "ctrl").lower()
mod_combo.set_active_id(current_mod)
mod_combo.connect(
"changed",
lambda c: self._save_key(
"double_click_modifier", c.get_active_id() or "ctrl"))
mod_row.add(mod_combo)
mod_row.set_activatable_widget(mod_combo)
# Sensitive only when the toggle above is on, so it's obvious
# the picker is doing nothing while the feature itself is off.
mod_row.set_sensitive(
bool(self._settings.get("double_click_popup_enabled", False)))
dbl_switch.connect(
"notify::active",
lambda s, _p: mod_row.set_sensitive(s.get_active()))
group.add(mod_row)
# Force-all-plugins modifier. Held while making a selection,