Repository navigation
Expand file tree
/
Copy pathblitcp_gui.py
More file actions
5881 lines (5402 loc) · 256 KB
/
Copy pathblitcp_gui.py
File metadata and controls
5881 lines (5402 loc) · 256 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
#!/usr/bin/env python3
# Copyright 2026 George Kapellakis
# Licensed under the Apache License, Version 2.0
# See LICENSE file for details.
"""blitcp — PySide6 modern GUI.
A faithful native reproduction of the blitcp modern look-and-feel mockup:
sidebar navigation, transfer composer with live progress simulation, connections
manager, remote/local file browser, history and settings — light & dark themes.
Icons use the bundled Tabler Icons webfont (assets/tabler-icons.ttf).
"""
import os
import base64
import re
import urllib.parse
import shutil
import subprocess
import tempfile
import sys
import threading
import time
from collections import deque
def _ensure_std_streams():
"""Give the process real std streams. Frozen, windowed builds (double-clicked
on Windows) have NO console, so sys.stdout/stderr are None — and blitcp.py
(a CLI module) reads sys.stdout.isatty() at import and writes to these streams,
so without this the engine import fails and the GUI silently loses it
(FC_OK=False). It also makes `--fc-core` a fully working CLI:
* launched from cmd WITH redirection (> file / | pipe) → wrap the inherited
OS handle so the output actually lands in the file/pipe;
* launched from cmd WITHOUT redirection → attach to the parent console and
print there;
* double-clicked (no console) → route to os.devnull so nothing crashes.
"""
if sys.stdout is not None and sys.stderr is not None:
return
if sys.platform.startswith("win"):
try:
import ctypes
import msvcrt
k = ctypes.windll.kernel32
k.GetStdHandle.restype = ctypes.c_void_p
k.GetStdHandle.argtypes = [ctypes.c_uint]
STD = {"stdout": 0xFFFFFFF5, "stderr": 0xFFFFFFF4, "stdin": 0xFFFFFFF6}
INVALID = ctypes.c_void_p(-1).value
def _wrap(which, mode):
"""Wrap an already-inherited std handle (redirection) as a file."""
h = k.GetStdHandle(STD[which])
if not h or h == INVALID:
return None
try:
fd = msvcrt.open_osfhandle(h, 0)
return os.fdopen(fd, mode, buffering=1)
except OSError:
return None
out = _wrap("stdout", "w") if sys.stdout is None else sys.stdout
err = _wrap("stderr", "w") if sys.stderr is None else sys.stderr
# Nothing inherited (no redirection) → attach to the parent console.
if out is None or err is None:
if k.AttachConsole(-1): # ATTACH_PARENT_PROCESS
if out is None:
out = open("CONOUT$", "w", buffering=1)
if err is None:
err = open("CONOUT$", "w", buffering=1)
if sys.stdin is None:
try:
sys.stdin = open("CONIN$", "r")
except OSError:
pass
if sys.stdout is None and out is not None:
sys.stdout = out
if sys.stderr is None and err is not None:
sys.stderr = err
except Exception:
pass
_devnull = open(os.devnull, "w")
if sys.stdout is None:
sys.stdout = _devnull
if sys.stderr is None:
sys.stderr = _devnull
_ensure_std_streams()
# Reuse the real blitcp engine for credentials + remote listing when it sits
# next to this file. Optional: the GUI still runs (with demo data) without it.
# The engine version the GUI fetches when it runs WITHOUT blitcp.py next to it
# (_download_core). Nothing else reads it: with the engine loaded, GUI_VERSION
# below is the engine's own __version__. Bump together with blitcp.py's
# __version__ on release — uat_gui GUI-VER-1 and the auditor fail otherwise.
_GUI_PIN = "4.2.15"
GUI_REPO = "gekap/blitcp"
# Shown only on the Settings version card, which the user opened on
# purpose. Static URL — nothing is fetched, nothing is tracked.
SUPPORT_URL = "https://ko-fi.com/blitcp"
SUPPORT_URL_ALT = "https://liberapay.com/blitcp"
try:
import blitcp as fc
# blitcp installs a CLI-style excepthook on import; restore the default so
# the GUI behaves normally.
sys.excepthook = sys.__excepthook__
FC_OK = True
except Exception:
try:
# Pre-rename engine sitting beside an updated GUI still loads.
import fast_copy as fc
sys.excepthook = sys.__excepthook__
FC_OK = True
except Exception:
fc = None
FC_OK = False
# One version for the window title, the Settings card and the update check: the
# engine's. A literal here drifted — v4.2.13 showed "blitcp 4.2.3".
GUI_VERSION = (getattr(fc, "__version__", None) if FC_OK else None) or _GUI_PIN
# ── i18n (see I18N_DESIGN.md) ───────────────────────────────────────────────
# The GUI shares the engine's gettext domain. Without the engine (demo mode)
# strings simply render in English. fc._ is a live function, so a later
# fc.set_language() re-routes every subsequent lookup.
I18N_CHOICES = [("en", "English"), ("el", "Ελληνικά"), ("zh_CN", "中文"),
("de", "Deutsch"), ("it", "Italiano"), ("es", "Español"),
("ja", "日本語")]
if FC_OK:
# getattr-guarded: a pre-rename engine loaded through the fallback below
# (fast-copy ≤3.12.x) predates i18n and has no _tr/set_language. The GUI
# must still RUN with it — English strings, not a crash.
def _tr(s):
f = getattr(fc, "_tr", None)
return f(s) if f else s
def set_language(lang):
f = getattr(fc, "set_language", None)
if f:
f(lang)
else:
def _tr(s):
return s
def set_language(lang):
pass
def _early_gui_language():
"""Apply the saved GUI language BEFORE any widget text is built.
Mirrors BlitcpGUI._config_dir()/_load_gui_settings(), which need an
instance that doesn't exist yet at this point in startup."""
try:
import json
if sys.platform.startswith("win"):
base = os.environ.get("APPDATA") or os.path.expanduser("~")
else:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(
os.path.expanduser("~"), ".config")
# Read-only here — new config dir first, pre-rename one as fallback
# (the instance _config_dir migrates it on first write).
lang = None
for d in ("blitcp", "fast_copy"):
try:
with open(os.path.join(base, d, "gui_settings.json")) as f:
lang = json.load(f).get("language")
break
except (OSError, ValueError):
continue
if lang:
set_language(lang)
except (OSError, ValueError):
pass
from PySide6.QtCore import (
Qt, QTimer, Signal, Slot, QThread, QObject, QPropertyAnimation, QEasingCurve,
QProcess, QProcessEnvironment,
)
from PySide6.QtGui import (
QFontDatabase, QFont, QColor, QPixmap, QPainter, QIcon, QTextCharFormat,
QTextCursor, QTextOption,
)
from PySide6.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton, QLineEdit, QComboBox,
QVBoxLayout, QHBoxLayout, QGridLayout, QStackedWidget, QScrollArea,
QDialog, QGraphicsOpacityEffect, QSizePolicy, QFrame, QFormLayout,
QFileDialog, QMessageBox, QCheckBox, QTextBrowser, QPlainTextEdit,
)
def _host_env():
"""The environment of the user's session, for programs the GUI launches
(file manager, browser). The frozen Linux build's
runtime hook points GIO at an empty module directory so its bundled libgio
leaves the host's newer gio modules alone (build.py, GIO_HOOK); a browser
or file manager started from here must get the host's GIO back, with the
host's libraries — so PyInstaller's LD_LIBRARY_PATH is undone too."""
import json
env = dict(os.environ)
saved = env.pop("_BLITCP_GIO_SAVED", None)
if saved:
try:
for k, v in json.loads(saved).items():
if v is None:
env.pop(k, None)
else:
env[k] = v
except (ValueError, AttributeError):
pass
if getattr(sys, "frozen", False) and "LD_LIBRARY_PATH" in env:
orig = env.pop("LD_LIBRARY_PATH_ORIG", None)
if orig:
env["LD_LIBRARY_PATH"] = orig
else:
env.pop("LD_LIBRARY_PATH")
return env
# Lines the visible transfer log keeps; older ones drop off the top. The full
# diagnostic transcript is _BoundedLog's job — this is only what is on screen.
LOG_MAX_LINES = 10000
class _BoundedLog:
"""Memory-bounded transcript for the on-failure diagnostic log.
The root cause of a failure is carried by the ENGINE'S STDERR lines (the GUI
feeds those in prefixed with "[stderr] "), which are low-volume — so we retain
ALL of them (bounded) regardless of WHERE in a million-line run they occur,
instead of a fixed head window that only ever captures startup banners. Plus
a tail of recent output for context. Memory stays bounded without dropping the
line that explains the failure."""
def __init__(self, errors=4000, tail=6000):
self._errors = deque(maxlen=errors) # stderr lines — the root-cause carriers
self._tail = deque(maxlen=tail) # recent output of any kind
def append(self, line):
self._tail.append(line)
if line.startswith("[stderr]"):
self._errors.append(line)
def extend(self, lines):
for ln in lines:
self.append(ln)
def lines(self):
tail = list(self._tail)
tail_set = set(tail)
# stderr/error lines that scrolled out of the tail window — the earliest
# (often root-cause) errors that a plain tail buffer would have lost.
earlier_errors = [e for e in self._errors if e not in tail_set]
out = []
if earlier_errors:
out.append("=== earlier error output (stderr) ===")
out.extend(earlier_errors)
out.append("=== recent output (tail) ===")
out.extend(tail)
return out
# ─────────────────────────────────────────────────────────── icon font ──
HERE = os.path.dirname(os.path.abspath(__file__))
FONT_PATH = os.path.join(HERE, "assets", "tabler-icons.ttf")
# name → PUA codepoint, extracted from tabler-icons 3.35.0 css
TI = {
"adjustments": "ea03", "alert-triangle": "ea06", "arrow-down": "ea16",
"arrow-down-circle": "ea11", "arrow-up-circle": "ea20", "arrows-exchange": "f1f4",
"bolt": "ea38", "book": "ea39", "brand-aws": "fa4c", "brand-azure": "fa4d",
"brand-google": "ec1f", "brand-python": "ed01", "bucket": "ea47", "check": "ea5e",
"chevron-right": "ea61", "cloud": "ea76", "corner-left-up": "ea7f",
"device-desktop": "ea89", "device-floppy": "eb62", "eye": "ea9a",
"file-text": "eaa2", "file-zip": "ed4e", "flask": "ebd2", "folder": "eaad",
"folders": "eaae", "history": "ebea", "key": "eac7", "lock-open": "eae1",
"moon": "eaf8", "pencil": "eb04", "player-play": "ed46", "plus": "eb0b",
"refresh": "eb13", "server-2": "f07c", "settings": "eb20", "sun": "eb30",
"trash": "eb41", "x": "eb55",
}
ICON_FAMILY = "tabler-icons"
# The Tabler icon font (subset to just the glyphs this GUI uses) is embedded as
# base64 so the app is a single self-contained .py — no assets/ folder needed on
# Windows/macOS/Linux. Falls back to assets/tabler-icons.ttf if present.
_ICON_FONT_B64 = (
"""AAEAAAALAIAAAwAwR1NVQrjmuNIAADVkAAAAKk9TLzJAKYCTAAAzOAAAAGBjbWFwpNudFQAAM5gAAAFEZ2x5ZkljIIoAAAC8AAAx
WmhlYWQxz4alAAAyiAAAADZoaGVhDFoIdQAAMxQAAAAkaG10eAPyAAAAADLAAAAAUmxvY2HPq9xLAAAyOAAAAFBtYXhwAD0BTgAA
MhgAAAAgbmFtZQQ0GH8AADTcAAAAaHBvc3QADQAAAAA1RAAAACAABgAAAAADeQMHAC8AXwCKAJgApgC0AAATJicmNTQ3Nj8BNTQ3
Njc2MzIWFxYdARcyFxYXFhcWBwYHBgcVFAcGBwYiJyYnJjUXJicmNTQ3Nj8BNTQ3Njc2MzIWFxYdARcyFxYXFhcWBwYHBgcVFAcG
BwYHIyInJjUTNDc2MzIWFRQXMhcWFxYXFgYHBgcVFAcGBwYiJyYnJj0BJicmNTQ3Nj8BFw4BFhcWFzMyNzY3NiYFDgEWHwEeATMy
Njc2JhcOARYfAR4BMzI2NzYm2hoVIwsSKwoBAgYNFg0YAgECBw4RDREICQYEDRcpAQEGDCwMBgEB+hoVIwsSKwoBAgYNFg0YAgEC
Bw4RDREICQYEDRcpAwURBAMKHAkF+ggMGA8ZAgcOEQ0RCAkMFBYhAQEGDCwMBgEBGxQjCxIrCiEREggTBAMKFwsGAQIf/fcREggT
AQQGBhEXAQIf5RESCBMBBAYGERcBAh8BbgkUIjcbGSoTBERFAwkJEBUNA0ZDAQkLEBQYHR8WFSUOl5kDCAkQEAkIA5ljCRQiNxsZ
KhMEwcEECQkQFQ0EwsABCQsQFBgdHxYVJQ4aHAoOCQIBFgweAoEODBIZEwUBCQsQFBgcPhkcC9bWBAgJEBAJCATW1gkUIzcaGCsT
BE0DHyMJAgERCQgWHYEDHyMJAQIBFwwWHf4DHyMJAQIBFwwWHQAEAAAAAAPMA0EAGwAuAEkAVAAAAT4BFxYXHgEXFgAXFgYHBgcG
IyEmJyYnJjc2AAE2NzYnJgAnJiMiBwYABwYXFhcBPgEWFxYdARQGDwEOAisBIi4BLwEuAT0BNBcOAR4BNz4BNTQmAZoQOiEkHgcQ
BAYBUgMPChgaKQQS/Us1GxcBAQ0CAVMBxRUEBAYD/q4DCRATCgP+rgQFAwUVATMEJCUFAQIDAQILCgcMBwkMAgEDAiARDwUbFg0R
IAMKGB4CARcGEAYJ/coGHkkcHgkBDCMdJyIZBQI1/WwIEw4MBgI0AwsMA/3NCAsOEggBpxYQERUEWEEUDAUBBAsFBQsEAQUMFEFY
/wMdIhQFBBcNFBkAAgAA/+4CpwMzAD4ASgAAARYHBgcGDwERNz4BNzYeAQcVBgcGBwYPAQ4BByMGJyMuAS8BJicmJyYnNSY+ARce
AR8BEScmJyY1NDc+ARcWBw4BFhcWNjc2LgEiAnkCAQENEycKHRgOCQ0YDQICAwQPDSUyEAsHAQcHAQcLEDIlDQ8EAwICDRgNCgwY
HgoXEh8jH10lKX0NEAELDyoJBwgWGALKCxAfGSMSBP5BHBgLAQINGA0BBwUHDw4lMg8HAQEBAQcPMiUODwcFBwENGA0CAQoXHgG/
BAoUIjA4IiAHGRwiBBkeCw8HEw4cEgABAAAAAAMkAuIALwAAAQ4BBwYdAScmJyYOARcVFhcWHwEWFxYXFhczFjY/ATY3Njc1Ni4B
BwYPATU0Jy4BAfAKDwIBXFoGEB4RAgEFCBxfRhgdCgYHAg0VfmgWBgQBAhEeEAZaXAEDIwLbBBELA/TyW1oECAkbDwIGBwocYEYY
GwgFAgIMfmgXCAYGAg8bCQgEWlvy8wQSFQAAAgAA/+sCqAMyADoASAAAATY3NhcWHwEeARcVFgYnLgEvAREXMhcWFxYXFgcGBw4B
JyYnLgE1NDc2PwERBw4BBwYuATc1PgE/ATYTDgEWFxYXMzI3Njc2JgHpCAkQDQIdVw4HAgMeFQkOGB0CBw4RDREICQYIHx1NIiQT
BwYLEisKHhgMCg0YDQICBw8yQRIREggTBAMKFwsGAQIfAysEAQIJAR5XDwsHARYdAwELGBz+QQEJCxAUGBwfKxsaChASJQ8aEhoY
KxMEAb8eFwoBAg0YDQEHCxAyQf1uAx8jCQIBEQkIFh0AAAIAAP/tA0oDNAAdADMAAAE2Fh8BFTMWFxYXFhUUBgAOASYvATUjJicm
NTQ2AAMGDwEUOwEeARcVNzY/ATQrAS4BJzUCDBErCAHJEAQKBwsE/rcRHhwGAcgRBBwEAVJyIDAZWVoPCAdoIDAZWVoPBwgDJg4K
GIKDAQEDBwsSDAn+ORQHDxKCgwEBCh0MCgHR/tQsQSMBCQgPrI8sQSMBCQgPrAAAAwAAAAADnwLqADYARgBYAAABNhcWHwE3Njc2
FxYXHgEXFhURBgcGLwEmJyYHBgcGBwYmJyYnJgcGBwYHBicmJwMQPgE3Njc2FyYnJg8CFTc2NzYXFhcRNwYPARE3Njc2FxYXNS8B
JicmASoYJUlADhBFSTw7LCcbFwUBCAgQFQ8YIDg5TEUSBgkPDRkhNzhLRRIJDgsNCwEFDA4XIkPRNjlQTAwBDRAeNjRHN8I1LQwN
EB42NEc3AQodIjkC5QMEBh4GBx8FBA0JEgsSDAIP/dsPCA0GCA4KEgEBJQkCAwMIDgsRAQIkCQIEBgcVARYBEhUOBwwNGG8XBQYh
BeLiBQUGCwECGAHDIAYUBf48BQUGCwECGOLhBQ0HDQADAAD/6gN0AzgAJgA/AFsAABM2NzY3NhcWFxYXHgEVFAYHBgcGBwYHBicm
JyYnJicmJyYnJj0BNgU0JyYnJicmKwEGBw4BFRQWFxYzMjc2NzYFMBcWFxYXFhcWMzI3Njc2NzY1NAcGBwYnJicmixFLQ2ZhZGdI
TxkFAwgVNBQYBxxbQloyHSogLw8EOToBBgECmBITIyYyOkIpSj44QUpARVRaSD4kIf3XBxgQLgEKHCtVNiM9DwctKwpJSo2KHi0J
An9DLSkQDwwNJik/DhMTICxOwklXED4XEAUCBggTHS8M0dUHHSYXBBMRExYSFQsNBBQSNhoeOxITFhMfHYIaUzypAhQLEQYLHA+k
nwUBBR8MFyYIFAQAAQAAAAADeAKKACUAAAEGBwYHAScmJyYnIyYGBwYdAQYXFhcWHwEWFxY2NzYANzYuASMiAz4ECQwr/rGgFQcG
BgIOGQYCAQUFGBFAXxEFCRUJBAGkBQoEFw8JAogBCAsq/rGgFAUEAQINDQQHCggGCRkRQF4RAgQBBQIBogcNIBYAAAEAAAAAAqYC
tQAtAAABDgEXFRYXFh8BBwYHBgcVBh4BNzM2NzY/AjY3Njc1Nic1JicmLwEmJyYnIyYBeBATAwIEBxjCwhgHBAICDRgNAgYFBhI7
mxYFBAIBAQIEBRbGHAoGBgQKArMEHBACBwYJGcLCGQkGBwINGA0CAQQEEjqbFggGBgIHBwIGBggWxhwHBQICAAACAAAAAAPQAuMA
KQBUAAABPgIXFhcWFxYdARceAgcOAQcGByMiJyYvASYnLgE3Njc2NzY3NjMyNwYHBgcGBwYHBgcOAhYXFh8BITc2NzY1NCcmJyYn
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
AgEQCQgFrAUNCQUGAQEBAwcLCQ4JDAUOMjGSVFlVUjcKAQoGCSMLBwMBAQEGBQkNA7ADCAkQEAkIAykmAw0TGSMoMjdKQD1RDAIC
BhwC3AcFEh8fC1IcGzlMUXQdEA4CDwUIBQ4oHkgtKxQcHUECAwMBAQEKDQQDChYMBgEBAQIKBgxcUA0FCgcL/rMEChAbVUdEVAcH
IiM8Cx8fEgUHCwcKBQ1QXAwGCgIBAQEGDCwMBgEBAQUPFxMaDxMhIHBFDQcNDgAEAAD/7AOgAzQAeQDvAQMBEAAANzQ3Njc2NTQn
JicmJyY3Njc2NzY3NjU0JyYnJjc+ATc2FxYXFjMyNz4BNzY3Njc2MzYXFhcWFxYXFjMyNzY3NhcWFxYXFgcGFhcWFxYUBwYHBhUU
FxYXFgYHBiMiJyYnJiIHBgcGBwYHBicmJy4BJyYjIgcGBwYnLgEBNCcmIyIHBgcGJyYnJicuAQYHBgcGIyInLgEiBwYVFBcWFxYH
BgcxDgIXFhcWFxYXFh0BFAcGBw4BFRQWNz4BMzIXFhceARcWMjc2NzY3Njc+ARcWFxYzMjYnLgE1NDc2NzY3NiYnJicmJyYnLgE1
NDc2NzYBJicmNjc2MxYXHgEHBgcGBwYHBjcOAhceAT4CJicmtgQDBgccExQWCw0GBQ8VJgsHCgcLAgMPDSoaFRUMEhEGDwcECwUI
DQ8VEg0QFhoUGQkCBgkPBxEUDhcbFhUcBwkZBxARJhYVFBcoGggJAgQREiAyEg0IDA4TCAsDBh0fJismDAgEDAUHDwcRFA4XGyQs
AjwFBxIJDRQXChAdFh4MBRsaAwgSIDMTDggZEggMBwoDAxYYKA0JBgMEFhESIwkCBAIGBAMcDBYZESofHAkDBgcFEwQIBAIDBggO
PB8SGg8FEREGCwcLFTIYAwEICAQMEwsUEAYGBAMGB/7CNhgWGCkuQzAnJSkCAhsUISIoLA8ZIgoKCzA4KAgYFxq6Eg0IDA4JGQcE
EBIaHiIbFB4LAgYJDwYRGREdHhkgBQQEAwgHCAUgCA8MDggGAwUHERUhCwcKCAkCBAgHFBkgKS4OGwMLHh1KHSAMBRoHERQOFzUU
IgQDBgcFBxAeGBkCAiALDgggBQkICQIECAw9AdAKCAwHCgMBAwQQFScUCw8RGRQkBAMNBQcSCQ0UFyYjJAoEBxMJDwYFDRgmBw0K
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
LgEnMS4BAd4mZFdRcBQVFBVBNkNNXQ8wDUmCMh4CAQEKDQQDCQwKDQYBAQECEwwF1QYICRASAQYOI1gIL6dcYlApHSsLAgICCDc0
oldcSzdECQIBBRweEwIJJS5LW44QBwQBAQIOIi4DECYSCwEnJQECAwYdAzMtKpNZXFphTz8lKwcBAQEJQDQhAywhCg4OBgIBBQcP
BBBebwQNEwIBAQEGDBYZCgEEAQEBDElSBwc/ICs/TA0xDl9KRkgICDkpdEUOAw8OBhoVS0hXOELJBg4IGUVYBAoRIi4CDAwjFAMn
JoYSCwYLDgACAAD/6QOlAzoAQQCCAAATNjc+ARcWFzEWFxYHDgEHBicmJyYnJiMiBw4BFxYXFhcWMzI/AScuASc1NjcxNjchHgEX
FhcWBgcGBwYnJicuAiUiBwYHBgcGBwYXFhcWFxY3Njc2NzY9ASMVFzMWFxYVFAcGBwYHBgcGJicmJyY3Njc2Nz4BHwE3NjQnJicm
Jy4BiCpKRa5XWkwXCRAPAnQGDwsIDxQQGyQ/LywnCgspFRUnM081CG0MBwwBAQUXAWIODAMBAgZfV1tvP0dSQ09cAgGKJy4zLDIf
JAoJEhMsMERNXV5JQiYj+gGMEAYJERIfIyoxNkB4KCsKBAIEFh8/Oos/CicJEBUYICEINAJIVTo3Kw8PNQ8NFhcEaQQIAgEJDAUJ
IiBoNTgoFQsVNQcCBQcShQsEEwkGDAwIGGrBPUALBxEUMDmwx+0REiImNDtJO0JFOD0gIwUINjBKRkUJFRUJBgoPFB4gHSITFwID
OjQ4RRkfNy9AKiYNHAUjCAIJCggLBQEBAAUAAP/vA6ADMQBWAIsAlgDQAN8AAAEyMzYzMhceARcWFxYdATMyFxYfAR4BFxUWFRYV
FAcOAQcGKwEVFAYHDgEHBisBIiYnJicmJy4BPQEjIicuASc1JjUmNTQ3PgE/ATY3NjsBNTQ2NzY3NhcOAQcGHQEzMhceAQYHBisB
BgcOAQcGBxUXFhcWOwE1NzY3PgE3NjcyNzY3NjQnJicmJyMGFzQ2HgEOASYnJicFFAcUBw4BBwYHBiMiBw4BBwYUFx4BFxYXMzY3
Njc2PQEjIicuATY3NjsBNjc+ATc2NzUnJicmJyYiAw4BHgE3Njc2NTQnJicmAZwBBzQqPhUeIxMZBwUzJgwJDAIfLAcCAQEEPiwF
MSwCBAo0IgUmZjIUDBMTHwwEAjMuDCUzCQIBAQQuIwINCQwlMgIECRIeMQsPAgFAQQQVEA8WBJeDFgQKDgMBAQEJEwMRTAEBAQoz
JQleYwMUBgICBhYEDZ4NCh8mEQ0eHwkCAQEjAQECDxAZKQxsUwMKDgIBAQIPCwQNng0EFgYBQEEEFRAPFgSXhBUECQ8DAQEBBgYJ
DgZRsREQAxcTCgkSCggNCgMwAQECDxMZHBEhGwEBBAELMSEBBw0UPGAHLD8FAUU5GQ0gLQcBAQQGDRglCxUWGwIJMyYBBw0UPGAH
JToLAQQBAUU5GQ0aFCBOBBEKBEFAAQUkJAUBAQEDDwoEEE1cFwYBOSkIBiUxCAEBAgkVB9UHFggBAQFSFxYKIx4PCRIEA0kYGCMO
FSESGwkDAgQRCgXXBQoRBAEBAQEIFgVBQAEFJCQFAQEBAw8JBQ1QXA0FCgIB/ogDGiEWAQEFDBgTCgkDAgAAAgAAAAADdQMMABYA
HwAAJSYnJicRNjc2FzIAFx4BFAYHBgArAQYTED8BNjQvASYBKw8JBgkNCA4TAQIqAwYHBwYD/dYBAggnAtLQ0NICGQEJBg8CsBQG
CQb+rQMFEhASBQP+rQIBd/78AoGAAoCBAgAACAAA/8YDTAMyAG0AfwCXAKoAvQDLAOYA9wAAATIzNjsBMhcyHwEWFxYXFhcWFREU
BxQPAQ4BByMGIyInJicmJzU0NzYzNzY3Nj8BNjc2NREmJyMiBhceAQYHBgcjIicmJyY2NzYmKwEHBgcGBwYRFRQXFh8BHgEXFg4B
JyYnJicmJxE2Nz4BNzYXMjM2MzIXFhUUBgcGIicuATYHNDc2NzYyFxYXFhQHBgcOASMiJyYnJicXNjIXHgEVFAYHBgcjJicmJyY2
BzY3NjsBFhceAQYHDgEiJicuARc2MhceAQYHBiInLgE2Bz4BFxYXFhUUBgcOAQcOASImJy4BLwE1NDc2FwYHBg8BFTM1NCc0JzEm
JyYBHgEHU0VwIQoHBQEHaW0EAwEBAgQBCzAgAg0bIgkNCQIBFQwdHgkFCAQBAwEBXFtVCgMDEQ4NEwQFGBwKEQYFDxADBA0sPwwF
CgMCAQECAQQZBAYHGBEUFh0HAQEBAQQdFhjzAQMQCxwMFhQQBicHFhIOXQ0ICQYzBgwICwoFBQYOFh0JDAkCAXYGKQYQFA4PBAY2
EAUIAgMVWQgKCBcZBQQQDwYOCQ0tDggQA3QGMQYVDxAUBTAGFRAPYiJcJSYJAQIEBhgPDBNUEgoTHAYCAQhpDQgEBQFTAQIECQ8D
MAECAwEFaGwJBQoPNP6oMw8LCQQfLQcDAwQSBAMKHAkFAQQDBQgCBAoOMAF/XFwBAQYhIgYBAQQFFBAfBQEBAQUFCA0K/t3UPxEN
BAIJEQkMHxMDAxUdJgctAgstBxYtDxGjAQUJHBAXAgEBAiQndxQLBwICAgMKCx8MBgMEAgMFEQQDIAEBAhcQDRYFAQEJBQgNEB5c
CAIBAQEFHR8JBQMDBQsoOwECBiMiBQIBBSQkcSIDGx0xCDYsEwwPGAYEAgEEBxwUClwXBSUVBAkGCy4vLh8KBwQKBw4AAAcAAAAA
A58DBwAtAE0AWQBqAIgAlgCnAAATNiAXHgEXFhQHBg8BFxYXFhQHBgcGBwYHBiAnLgEnJjQ3Nj8BJyYnJjQ3Njc2FwYHBgcGBxUU
Fx4BFxYgNz4BNzY1NCYvASYnJiclIgcXMhcWFRQGJicmPgEXNDc2OwEXHgEHBg8BIiYnJgcOAQcGFRQWHwEWFxYXITY3Nj8BPgE1
NCcuAScmIBc0NhYXFhUUBiYvAS4BNwYVFBcWFyE3PgEnJi8BIwbwCAIMBztSBgEBBSoICCoFAQECCQ4cJTkJ/fgJO1IGAQEFKggI
KgUBAQYoKUcVEBkLAQECBSMYDgH6DhgjBQICBQELFQwT/v95hSoKChMbJAsJAhaRHAOIhwUODwYGF4WBFAcMuhgiBQICBQEKEQoc
AggdCg8LAQUCAgUjGA7+AwYgJgoCHyYJAQIBxBwHBRIBEAUODwYHF4VyFQMGAQEIUzsKXgo6LAgILDoKXgoWFyYZIQkBAQhTOwte
CjgtCAgsOgpeCjkqLEwCCw8hBAouLw4YJAUCAgUkGA4vLBILARcMBwUBAVMGChoVFgQPDB8XKiAJAQMHHg8RCgEFBgu/BSQYDi8s
EgsBFQsHCQoGChYBCxIsLw4YJAUCfRQZBxUFDBcWCRMBBAYuCh0RCQYKAwceDxEKAQEAAgAAAAADoQK4ACEARQAAASY+ARcWHwEW
FxYXFRYGBwYgJy4BNj8BNjc2MyEvAS4BJwEOARcVFhcWHwEeARcWNz4BJzUuAS8CITI3Nj8BPgEmJyYgAqUDFCAOBldAEgYDAgIU
EQf9uwcTEwcQAQMHCSABwz8eCQQB/dsRFAMCBAUTexUUBwQJERUDAQQJHj8BpTQPCgQCEAYUEgb9uAKEEhoHCQRXQBQHBQcCEB4C
AQEDHiMKAQIBAT8fCQkG/uMDHRACBgYHFHoVEAIBAgMcEAEGCQkfPwEBAwEKIx0DAQAGAAD/7AOhAt4AKABhAJYAoQDLAO4AABM2
MzIXFh8CFAcGBwYiJyYnJj0BIxUUBwYPAQ4BIyInJicmNT8BPgEXNjc2MzIeAx8BNzY3NjIXFh8BNzY3Njc2NzY7ATIXFhcHBgcG
IyImJyYvAQcGBw4BIyInJicmJTY7ARYXHgEGBwYuAScjFTMyFxYVFgcGBwYHIyInLgI2FxYXFhczNSMiJy4BJyY3NDc+AQUmBgcG
BxUzNTQmATY3NhcWFxYXHgEHBgcGBwYHBiYnJjc2NzY3NiYnJicmJyYnJicmPQE0BQYHBhcWFxYXFhcWNzY3Njc2JicmBgcOASci
JyYnJicuASKxBRIlHBkJAgEBAgoMIgwKAgEpAwURAQQGBhEMCgIBAQIIK8UDCAsUCQsNBQcHDAsOBwolCgcNCwwIAwQGAwwEBwYQ
CA4EGhoDCxoODwcGDhMTDgYHDw4aCwIaHAG6CiEgCAUZHwETDBwVAisaPBcQAQcOKwQIIiMNFB8BGxUPCgUFKhwcDA8hBgQBAwYi
/doJEQYBASkCAdAJEAkVWzYWCQoGAQQRDhQSDRAkBQcPEw0KBQMDDxMVHBsTBw0JAv3TCgkMAgMZLjtrXUpQQDYvCA0OFQkODjyE
QBUjMDA9Ng4LFALcARgWIQpmaAQMCgwMCgwDGBYWGQkOCQECAQwKDARoZgoeKRwPCQwFCwocJT8XHggJCggdFz8pCxEGBQgCBQkb
g4MGFgkNCh8pKR8KDQkWBICFKwIBAQcoLQ0IAxEMKiEWKyMWJg0BAQMGJSwcBAILBgwqBAQhFBEgGQoXIVEHBQkCBj8iHQv+oxEF
AwEGGwsHBxQTODcvJCAGBw0RExkgKB4dGAMFBgQFAQEDBg8EBwwHOQQLDQ8UEiIeNgUCDw0VEgwRKQcDAwYbGwEICxUbKAsFAAQA
AAAAA8sDXgAZAEAATABhAAABNhceAQcGAgcOASMGIyInLgE1NDc2PwI2FzY3NjMyFxYXARYHBgcGByEmJyYnJjU0NzY3Nj8BNicm
JyY3Njc2DwIGHQEzMj8BNiMXBg8BFBcWFx4BFRQPAQ4BOwEDBwYB5xQQDw8BAf4EBw8WEDZXCg0RAgVRVZB0cQQGDBANCAUJASgE
BwcSBCf9+isECgcMAwYOAmK1A1A3AQYCAj889jVFRSUlAVZVAcQKEQw0ZAMDARM9PAOPj+AMDgNUCgYEGw0H/YYFCQYBAgMWDQcI
DsDJhWqLBwUJBQML/YUODxIFAQEBAQQHCxEJCA4HAiREAWNFAwoOCZSOXTKiogMCAdbVohknHAFAfgcFBgcVDRgYAQHgGiAAAAAA
AQAAACcBTQAVAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAABBgGPAgUCUgLGAxgDpgQyBHMEvQU/Ba4GFwayB3IH4QiqCZEKTArS
CyMLlQxSDeEO4g+fEAMQnxD0EaoSbhOtE+YVTRZKFroYFxitAAEAAAABAABjGSblXw889QALA+gAAAAA5O4SgwAAAADk7hKDAAD/
mQjUISEAAAAIAAIAAAAAAAAAAAAAA/IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAA4T/nAAACNYAAAAACNQAAQAAAAAAAAAAAAAAAAAAAAIABAPyAZAABQAABZsC
vAAAAIwFmwK8AAAB4AAxAQIAAAIABQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUGZFZADA6gP6TQOE/5wAWiEhAGcAAAABAAAAAAAA
AAAAAAAAAAIAAAACAAAAAwAAABQAAwABAAAAFAAEATAAAABIAEAABQAI6gPqBuoR6hbqIOo56kfqXuph6nbqf+qJ6prqouqu6sfq
4er46wTrC+sT6yDrMOtB61XrYuvS6+rsH+0B7UbtTvB88fT6Tf//AADqA+oG6hHqFuog6jjqR+pe6mHqdup/6onqmuqi6q3qx+rh
6vjrBOsL6xPrIOsw60HrVeti69Lr6uwf7QHtRu1O8Hzx9PpM//8V/hX8FfIV7hXlFc4VwRWrFakVlRWNFYQVdBVtFWMVSxUyFRwV
ERULFQQU+BTpFNkUxhS6FEsUNBQAEx8S2xLUD6cOMAXZAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAEIAAwABBAkAAQAYAAAAAwABBAkAAgAOABgAAwABBAkAAwAYAAAA
AwABBAkABAAYAAAAAwABBAkABgAYAAAAdABhAGIAbABlAHIALQBpAGMAbwBuAHMAUgBlAGcAdQBsAGEAcgADAAAAAAAAAAoAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAOAAoADAAAAAAAAkRGTFQADmxhdG4AEgAIAAAAAAAAAAD//wAAAAA="""
)
def load_icon_font():
"""Register the icon font and set ICON_FAMILY. Returns True on success."""
global ICON_FAMILY
from PySide6.QtCore import QByteArray
fid = -1
try:
raw = base64.b64decode(_ICON_FONT_B64)
fid = QFontDatabase.addApplicationFontFromData(QByteArray(raw))
except Exception:
fid = -1
if fid == -1 and os.path.exists(FONT_PATH):
fid = QFontDatabase.addApplicationFont(FONT_PATH)
fams = QFontDatabase.applicationFontFamilies(fid) if fid != -1 else []
if fams:
ICON_FAMILY = fams[0]
return True
return False
def ic(name):
"""Return the unicode glyph for a tabler icon name."""
cp = TI.get(name)
return chr(int(cp, 16)) if cp else "?"
def make_app_icon():
"""Build the window/taskbar icon: green bolt on a dark rounded badge.
Multi-size so it stays crisp in titlebars, taskbars and alt-tab."""
icon = QIcon()
for s in (16, 24, 32, 48, 64, 128, 256):
pm = QPixmap(s, s)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setRenderHint(QPainter.TextAntialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor("#16171d"))
r = s * 0.16
p.drawRoundedRect(0, 0, s, s, r, r)
f = QFont(ICON_FAMILY)
f.setPixelSize(int(s * 0.64))
p.setFont(f)
p.setPen(QColor("#2fe06a"))
p.drawText(pm.rect(), Qt.AlignCenter, ic("bolt"))
p.end()
icon.addPixmap(pm)
return icon
# ───────────────────────────────────────────────────────────── themes ──
LIGHT = {
"bg": "#f6f7f9", "panel": "#ffffff", "p2": "#f1f3f6", "line": "#cfd3da",
"line2": "#bcc1ca", "txt": "#1a1d23", "muted": "#6b7280", "faint": "#9ca3af",
"accent": "#6366f1", "accenth": "#4f46e5", "asoft": "#eef0fe", "atx": "#4338ca",
"ok": "#15803d", "okbg": "#e9f7ee", "warn": "#b45309", "warnbg": "#fdf3e3",
"danger": "#dc2626", "side": "#16171d", "sidetx": "#b9bcc7", "sideact": "#23252e",
"sideico": "#7c7f8c",
}
DARK = {
"bg": "#0f1014", "panel": "#1a1c22", "p2": "#23262e", "line": "#333845",
"line2": "#414755", "txt": "#e9eaee", "muted": "#9aa0ab", "faint": "#676d78",
"accent": "#818cf8", "accenth": "#a5aefc", "asoft": "#262a48", "atx": "#c7caff",
"ok": "#4ade80", "okbg": "#16271c", "warn": "#fbbf24", "warnbg": "#2a2113",
"danger": "#f87171", "side": "#0b0c10", "sidetx": "#b9bcc7", "sideact": "#1c1f27",
"sideico": "#6a6e7b",
}
MONO = "'SF Mono','DejaVu Sans Mono',Menlo,Consolas,monospace"
def stylesheet(v):
"""Build the global QSS from a theme variable map."""
return f"""
* {{ font-family: 'Inter','Segoe UI',-apple-system,sans-serif,'{ICON_FAMILY}';
font-size: 13px; color: {v['txt']}; outline: none; }}
QWidget#root {{ background: {v['bg']}; }}
/* sidebar */
QWidget#side {{ background: {v['side']}; }}
QLabel#brandName {{ color: #fff; font-size: 13px; font-weight: 700; letter-spacing: 0.5px; }}
QLabel#brandLogo {{ color: #2fe06a; font-family: '{ICON_FAMILY}'; font-size: 18px;
background: #1d1f27; border: 1px solid #2a2c35;
border-radius: 9px; min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
qproperty-alignment: AlignCenter; }}
QLabel#brandVer {{ color: {v['sideico']}; font-size: 10px; border: 1px solid #2a2c35;
border-radius: 8px; padding: 2px 7px; }}
QPushButton#navitem {{ color: {v['sidetx']}; background: transparent; border: none;
border-radius: 10px; padding: 10px 12px; text-align: left; font-size: 13px; }}
QPushButton#navitem:hover {{ background: {v['sideact']}; }}
QPushButton#navitem[active="true"] {{ background: {v['sideact']}; color: #fff; font-weight: 500; }}
QPushButton#themebtn {{ color: {v['sidetx']}; background: {v['sideact']}; border: none;
border-radius: 10px; padding: 9px 12px; text-align: left; font-size: 12px; }}
QLabel#sfoot {{ color: {v['sideico']}; font-size: 10px; }}
/* content */
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: none; }}
QLabel#h2 {{ font-size: 19px; font-weight: 600; }}
QLabel#sub {{ font-size: 12px; color: {v['muted']}; }}
QLabel#lbl {{ font-size: 11px; color: {v['muted']}; font-weight: 500; }}
QLabel#sectl {{ font-size: 11px; color: {v['faint']}; font-weight: 600; }}
QLabel#pill {{ font-size: 10px; background: {v['asoft']}; color: {v['atx']};
border-radius: 10px; padding: 3px 10px; }}
/* rows */
QFrame#row {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 8px; }}
QFrame#row[warn="true"] {{ border: 1px solid {v['warn']}; }}
QLabel#rowicon {{ color: {v['faint']}; font-family: '{ICON_FAMILY}'; font-size: 17px; }}
QLabel#path {{ font-family: {MONO}; font-size: 12px; color: {v['txt']}; }}
QLineEdit#path {{ font-family: {MONO}; font-size: 12px; color: {v['txt']};
border: none; background: transparent; }}
/* tags */
QPushButton#tag {{ font-size: 10px; font-weight: 500; border-radius: 11px; padding: 4px 10px;
border: none; }}
QPushButton#tag[state="on"] {{ background: {v['accent']}; color: #fff; }}
QPushButton#tag[state="off"] {{ background: transparent; color: {v['faint']}; }}
QPushButton#tag[state="off"]:hover {{ color: {v['muted']}; }}
QPushButton#tag[state="dis"] {{ background: transparent; color: {v['line2']}; }}
QPushButton#rm {{ color: {v['faint']}; font-family: '{ICON_FAMILY}'; font-size: 17px;
border: none; background: transparent; border-radius: 7px; padding: 3px; }}
QPushButton#rm:hover {{ color: {v['danger']}; background: {v['warnbg']}; }}
/* add source button */
QPushButton#add {{ background: transparent; border: 1.5px dashed {v['line2']};
border-radius: 8px; color: {v['muted']}; font-size: 12px; min-height: 40px; }}
QPushButton#add:hover {{ border-color: {v['accent']}; color: {v['accent']};
background: {v['asoft']}; }}
/* warn bar */
QFrame#warnbar {{ background: {v['warnbg']}; border-radius: 8px; }}
QLabel#warntext {{ color: {v['warn']}; font-size: 12px; }}
QLabel#warnicon {{ color: {v['warn']}; font-family: '{ICON_FAMILY}'; font-size: 16px; }}
QLabel#arrow {{ color: {v['faint']}; font-family: '{ICON_FAMILY}'; font-size: 18px; }}
/* option pills */
QPushButton#opt {{ font-size: 11px; font-weight: 500; padding: 7px 13px; border-radius: 11px;
background: {v['p2']}; color: {v['muted']}; border: 1px solid transparent; }}
QPushButton#opt:hover {{ border-color: {v['line2']}; }}
QPushButton#opt[active="true"] {{ background: {v['asoft']}; color: {v['atx']};
border-color: transparent; }}
QLabel#optlbl {{ font-size: 12px; color: {v['muted']}; }}
QComboBox#mini {{ min-height: 32px; border: 1px solid {v['line']}; border-radius: 9px;
background: {v['panel']}; font-size: 12px; padding: 0 8px; color: {v['txt']}; }}
QComboBox#mini::drop-down {{ border: none; width: 20px; }}
QComboBox#mini::down-arrow {{ image: none; border-left: 4px solid transparent;
border-right: 4px solid transparent; border-top: 5px solid {v['muted']};
margin-right: 6px; }}
QComboBox QAbstractItemView {{ background: {v['panel']}; color: {v['txt']};
border: 1px solid {v['line']}; selection-background-color: {v['asoft']};
selection-color: {v['atx']}; }}
/* buttons */
QPushButton#btn {{ min-height: 42px; border-radius: 8px; border: 1px solid {v['line2']};
background: {v['panel']}; color: {v['txt']}; font-size: 13px; font-weight: 500;
padding: 0 17px; }}
QPushButton#btn:hover {{ border-color: {v['accent']}; color: {v['accent']}; }}
QPushButton#btn:disabled {{ color: {v['faint']}; border-color: {v['line']}; }}
QPushButton#btnprimary {{ min-height: 42px; border-radius: 8px; border: 1px solid {v['accent']};
background: {v['accent']}; color: #fff; font-size: 13px; font-weight: 500; padding: 0 17px; }}
QPushButton#btnprimary:hover {{ background: {v['accenth']}; border-color: {v['accenth']}; }}
QPushButton#btnprimary:disabled {{ background: {v['line2']}; border-color: {v['line2']};
color: {v['panel']}; }}
QPushButton#btnsm {{ min-height: 34px; border-radius: 8px; border: 1px solid {v['line2']};
background: {v['panel']}; color: {v['txt']}; font-size: 12px; font-weight: 500; padding: 0 13px; }}
QPushButton#btnsm:hover {{ border-color: {v['accent']}; color: {v['accent']}; }}
QPushButton#btnsmprimary {{ min-height: 34px; border-radius: 8px; border: 1px solid {v['accent']};
background: {v['accent']}; color: #fff; font-size: 12px; font-weight: 500; padding: 0 13px; }}
QPushButton#btnsmprimary:hover {{ background: {v['accenth']}; border-color: {v['accenth']}; }}
/* advanced panel */
QFrame#adv {{ border: 1px solid {v['line']}; border-radius: 8px; background: {v['panel']}; }}
QLabel#gl {{ font-size: 10px; color: {v['faint']}; font-weight: 600; }}
QLabel#advlbl {{ font-size: 11px; color: {v['muted']}; }}
QLineEdit#advinput {{ min-height: 34px; border: 1px solid {v['line']}; border-radius: 9px;
background: {v['bg']}; font-size: 12px; padding: 0 10px; color: {v['txt']}; }}
QLineEdit#advinput:focus {{ border-color: {v['accent']}; }}
QFrame#chips {{ border: 1px solid {v['line']}; border-radius: 9px; background: {v['bg']}; }}
QFrame#chip {{ background: {v['p2']}; border-radius: 7px; }}
QLabel#chiptext {{ font-family: {MONO}; font-size: 11px; color: {v['txt']}; }}
QPushButton#chipx {{ color: {v['faint']}; font-family: '{ICON_FAMILY}'; font-size: 13px;
border: none; background: transparent; }}
QPushButton#chipx:hover {{ color: {v['danger']}; }}
QPushButton#chipadd {{ color: {v['muted']}; border: none; background: transparent; font-size: 12px; }}
QPushButton#chipadd:hover {{ color: {v['accent']}; }}
/* progress */
QFrame#prog {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 10px; }}
QLabel#pstate {{ font-size: 13px; font-weight: 600; }}
QLabel#pfiles {{ font-size: 12px; color: {v['muted']}; font-family: {MONO}; }}
QFrame#bar {{ background: {v['p2']}; border-radius: 5px; }}
QFrame#barf {{ border-radius: 5px; }}
QLabel#statk {{ font-size: 10px; color: {v['faint']}; font-weight: 500; }}
QLabel#statv {{ font-size: 17px; font-weight: 600; }}
QLabel#statvok {{ font-size: 17px; font-weight: 600; color: {v['ok']}; }}
QFrame#logbox {{ background: {v['bg']}; border: 1px solid {v['line']}; border-radius: 9px; }}
QPlainTextEdit#logview {{ font-family: {MONO}; font-size: 12.5px; color: {v['muted']};
background: transparent; border: none; padding: 7px 9px; }}
/* connection cards */
QFrame#conn {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 8px; }}
QLabel#connicon {{ color: {v['accent']}; font-family: '{ICON_FAMILY}'; font-size: 19px; }}
QLabel#connname {{ font-size: 13px; font-weight: 600; }}
QLabel#connmeta {{ font-size: 11px; color: {v['faint']}; font-family: {MONO}; }}
QLabel#badgeok {{ font-size: 10px; background: {v['okbg']}; color: {v['ok']};
border-radius: 10px; padding: 4px 10px; }}
QLabel#badgeidle {{ font-size: 10px; background: {v['p2']}; color: {v['muted']};
border-radius: 10px; padding: 4px 10px; }}
QLabel#badgetest {{ font-size: 10px; background: {v['warnbg']}; color: {v['warn']};
border-radius: 10px; padding: 4px 10px; }}
QPushButton#ib, QPushButton#ibdel {{ color: {v['faint']}; font-family: '{ICON_FAMILY}';
font-size: 17px; border: none; background: transparent; border-radius: 8px; padding: 5px; }}
QPushButton#ib:hover {{ color: {v['accent']}; background: {v['asoft']}; }}
QPushButton#ibdel:hover {{ color: {v['danger']}; background: {v['warnbg']}; }}
/* browser */
QFrame#crumb {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 8px; }}
QLabel#crumbico {{ color: {v['muted']}; font-family: '{ICON_FAMILY}'; font-size: 15px; }}
QPushButton#crumbc {{ color: {v['accent']}; font-family: {MONO}; font-size: 12px;
border: none; background: transparent; }}
QLabel#crumbsep {{ color: {v['muted']}; font-family: '{ICON_FAMILY}'; font-size: 15px; }}
QLabel#crumbpart {{ color: {v['muted']}; font-family: {MONO}; font-size: 12px; }}
QFrame#fitem {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 8px; }}
QFrame#fitem[sel="true"] {{ border-color: {v['accent']}; background: {v['asoft']}; }}
QLabel#fi {{ color: {v['muted']}; font-family: '{ICON_FAMILY}'; font-size: 18px; }}
QLabel#fidir {{ color: {v['accent']}; font-family: '{ICON_FAMILY}'; font-size: 18px; }}
QLabel#fn {{ font-family: {MONO}; font-size: 13px; color: {v['txt']}; }}
QLabel#fs {{ color: {v['faint']}; font-size: 11px; font-family: {MONO}; }}
/* history */
QFrame#hrun {{ background: {v['panel']}; border: 1px solid {v['line']}; border-radius: 8px; }}
QLabel#hp {{ font-family: {MONO}; font-size: 12px; font-weight: 500; }}
QLabel#hs {{ font-size: 11px; color: {v['muted']}; }}
/* dialogs */
QDialog {{ background: {v['panel']}; }}
QLabel#sheettitle {{ font-size: 16px; font-weight: 600; }}
QLabel#fieldlbl {{ font-size: 11px; color: {v['muted']}; font-weight: 500; }}
QLineEdit#fieldinput, QComboBox#fieldinput {{ min-height: 38px; border: 1px solid {v['line']};
border-radius: 9px; background: {v['bg']}; font-size: 13px; padding: 0 11px; color: {v['txt']}; }}
QLineEdit#fieldinput:focus, QComboBox#fieldinput:focus {{ border-color: {v['accent']}; }}
QComboBox#fieldinput::drop-down {{ border: none; width: 22px; }}
QComboBox#fieldinput::down-arrow {{ image: none; border-left: 4px solid transparent;
border-right: 4px solid transparent; border-top: 5px solid {v['muted']}; margin-right: 7px; }}
QScrollBar:vertical {{ background: transparent; width: 9px; margin: 0; }}
QScrollBar::handle:vertical {{ background: {v['line2']}; border-radius: 4px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QScrollBar:horizontal {{ background: transparent; height: 9px; }}
QScrollBar::handle:horizontal {{ background: {v['line2']}; border-radius: 4px; min-width: 24px; }}
"""
# ─────────────────────────────────────────────────────── helper widgets ──
def repolish(w):
w.style().unpolish(w)
w.style().polish(w)
w.update()
class ClickLabel(QLabel):
clicked = Signal()
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton:
self.clicked.emit()
super().mousePressEvent(e)
class ClickFrame(QFrame):
clicked = Signal()
doubleClicked = Signal()
def mousePressEvent(self, e):
if e.button() == Qt.LeftButton:
self.clicked.emit()
super().mousePressEvent(e)
def mouseDoubleClickEvent(self, e):
if e.button() == Qt.LeftButton:
self.doubleClicked.emit()
super().mouseDoubleClickEvent(e)
def iconbtn(name, oid="ib", size=None):
b = QPushButton(ic(name))
b.setObjectName(oid)
b.setCursor(Qt.PointingHandCursor)
b.setFont(QFont(ICON_FAMILY))
if size:
b.setFixedSize(*size)
return b
def textbtn(text, icon=None, oid="btn"):
label = (ic(icon) + " " + text) if icon else text
b = QPushButton(label)
b.setObjectName(oid)
b.setCursor(Qt.PointingHandCursor)
b.setFont(_mixed_font())
return b
def _mixed_font():
# Inter for text but the glyphs come from the icon font; Qt falls back per-glyph,
# so we set a normal UI font and rely on font-substitution for the PUA glyphs.
f = QFont("Inter")
return f
def fmt_size(n):
units = ["B", "KB", "MB", "GB", "TB"]
i = 0
while n >= 1024 and i < len(units) - 1:
n /= 1024.0
i += 1
return (f"{n:.1f}" if i else f"{int(n)}") + " " + units[i]
def parse_size(text):
"""'8.3 GB' / '512KB' / '1.2GiB' → bytes (int). None on failure."""
m = re.match(r"\s*([\d.]+)\s*([KMGTP]?)i?B\s*$", text, re.I)
if not m:
return None
mult = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3,
"T": 1024**4, "P": 1024**5}[m.group(2).upper()]
try:
return int(float(m.group(1)) * mult)
except ValueError:
return None
def fmt_time(s):
s = round(s)
return f"{s}s" if s < 60 else f"{s // 60}m {s % 60}s"
def mask(v):
if not v:
return ""
v = str(v)
return v[:2] + "…" + v[-2:] if len(v) > 6 else "••••"
def base(p):
p = re.sub(r"[\\/]+$", "", p)
return re.split(r"[\\/]", p)[-1]
def is_remote(p):
return bool(re.search(r"@.+:", p) or re.search(r"://", p))
# ──────────────────────────────────────────────────────────── toast ──
class Toast(QLabel):
def __init__(self, parent):
super().__init__(parent)
self.setStyleSheet(
"background:#16171d; color:#fff; font-size:12px; font-weight:500;"
"padding:10px 16px; border-radius:11px;")
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self._eff = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self._eff)
self._eff.setOpacity(0.0)
self._anim = QPropertyAnimation(self._eff, b"opacity")
self._anim.setDuration(200)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._fade_out)
self.hide()
def show_msg(self, msg):
self.setText(ic("check") + " " + msg)
self.adjustSize()
self._reposition()
self.show()
self.raise_()
self._anim.stop()
self._anim.setStartValue(self._eff.opacity())
self._anim.setEndValue(1.0)
self._anim.start()
self._timer.start(1700)
def _fade_out(self):
self._anim.stop()
self._anim.setStartValue(self._eff.opacity())
self._anim.setEndValue(0.0)
self._anim.start()
def _reposition(self):
p = self.parent()
x = (p.width() - self.width()) // 2
y = p.height() - self.height() - 18
self.move(x, y)
# ───────────────────────────────────────────────────── modal dialogs ──
class PasswordDialog(QDialog):
"""Generic password / passphrase prompt (matches #pwModal)."""
def __init__(self, parent, title, sub, label, allow_empty=False,
confirm=False, generator=False):
super().__init__(parent)
self.allow_empty = allow_empty
self.confirm = confirm
# Offer a diceware generate button only when SETTING a passphrase and
# the engine provides the generator (a pre-rename engine may not).
self.generator = (generator and confirm and FC_OK
and hasattr(fc, "generate_passphrase"))
self.setWindowTitle(title)
self.setModal(True)
self.setFixedWidth(400)
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 22, 24, 22)
lay.setSpacing(0)
t = QLabel(title)
t.setObjectName("sheettitle")
lay.addWidget(t)
if sub:
lay.addSpacing(8)
s = QLabel(sub)
s.setObjectName("sub")
s.setWordWrap(True)
s.setTextFormat(Qt.RichText)
lay.addWidget(s)
lay.addSpacing(14)
ll = QLabel(label)
ll.setObjectName("fieldlbl")
lay.addWidget(ll)
lay.addSpacing(5)
self.input = QLineEdit()
self.input.setObjectName("fieldinput")
self.input.setEchoMode(QLineEdit.Password)
self.input.setPlaceholderText("••••••••")
self.input.returnPressed.connect(self._confirm)
lay.addWidget(self.input)
self.input2 = None
if confirm:
lay.addSpacing(12)
ll2 = QLabel(_tr("Confirm passphrase"))
ll2.setObjectName("fieldlbl")
lay.addWidget(ll2)
lay.addSpacing(5)
self.input2 = QLineEdit()
self.input2.setObjectName("fieldinput")
self.input2.setEchoMode(QLineEdit.Password)
self.input2.setPlaceholderText("••••••••")
self.input2.returnPressed.connect(self._confirm)
lay.addWidget(self.input2)
self.err = QLabel("")
self.err.setObjectName("sub")
self.err.setWordWrap(True)
self.err.setStyleSheet("color:#e5484d;")
self.err.hide()
lay.addSpacing(8)
lay.addWidget(self.err)
lay.addSpacing(18)
row = QHBoxLayout()
if self.generator:
gen = textbtn(_tr("Generate"), "bolt", oid="btn")
gen.setToolTip(_tr("Fill in a random 6-word passphrase (~77 bits)"))
gen.clicked.connect(self._generate)
row.addWidget(gen)
row.addStretch(1)
cancel = textbtn(_tr("Cancel"), oid="btn")
cancel.clicked.connect(self.reject)
ok = textbtn(_tr("Confirm"), "lock-open", oid="btnprimary")
ok.clicked.connect(self._confirm)
row.addWidget(cancel)
row.addWidget(ok)
lay.addLayout(row)
QTimer.singleShot(40, self.input.setFocus)
def _generate(self):
"""Fill both fields with a fresh diceware passphrase and reveal it —
the user must be able to read it to store it in a password manager."""
phrase, bits = fc.generate_passphrase()
for w in (self.input, self.input2):
if w is not None:
w.setText(phrase)
w.setEchoMode(QLineEdit.Normal)
self.err.setStyleSheet("color:#30a46c;")
self.err.setText(_tr("Generated ({0} bits). Save it in your password "
"manager now — it is shown only here.").format(bits))
self.err.show()
def _confirm(self):
txt = self.input.text()
if self.confirm:
if not txt and not self.allow_empty:
return self._fail("Enter a passphrase.")
if txt != self.input2.text():
return self._fail("Passphrases do not match.")
return self.accept()
if txt or self.allow_empty:
self.accept()
def _fail(self, msg):
self.err.setStyleSheet("color:#e5484d;") # back to red after a green Generate note
self.err.setText(msg)
self.err.show()
FIELDSETS = {
"s3": [
("endpoint_url", "Endpoint URL (blank = AWS)", "MinIO / R2 / Wasabi", False),
("access_key_id", "Access key ID", "", False),
("secret_access_key", "Secret access key", "", True),
("region", "Region", "us-east-1", False),
("container", "Default bucket", "", False),
("prefix", "Default prefix", "", False),
],
"az": [
("connection_string", "Connection string (blank = account+key)", "", True),
("account", "Account name", "", False),
("key", "Account key", "", True),