trackerkeeper 0.1.0__py3-none-any.whl

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 (89) hide show
  1. trackerkeeper/__init__.py +55 -0
  2. trackerkeeper/__main__.py +3 -0
  3. trackerkeeper/_version.py +24 -0
  4. trackerkeeper/app.py +445 -0
  5. trackerkeeper/assets/__init__.py +2 -0
  6. trackerkeeper/assets/trackerkeeper.svg +12 -0
  7. trackerkeeper/async_io.py +299 -0
  8. trackerkeeper/autostart/__init__.py +52 -0
  9. trackerkeeper/autostart/_linux.py +129 -0
  10. trackerkeeper/autostart/_macos.py +163 -0
  11. trackerkeeper/autostart/_msix.py +181 -0
  12. trackerkeeper/autostart/_unsupported.py +22 -0
  13. trackerkeeper/autostart/_windows.py +96 -0
  14. trackerkeeper/bake.py +217 -0
  15. trackerkeeper/blur/__init__.py +212 -0
  16. trackerkeeper/blur/_dwm.py +348 -0
  17. trackerkeeper/blur/_faux_frost.py +143 -0
  18. trackerkeeper/blur/_kwin.py +478 -0
  19. trackerkeeper/blur/_macos.py +356 -0
  20. trackerkeeper/blur/_unsupported.py +39 -0
  21. trackerkeeper/boot_timing.py +75 -0
  22. trackerkeeper/breadboard.py +1554 -0
  23. trackerkeeper/bus.py +110 -0
  24. trackerkeeper/catalog.py +176 -0
  25. trackerkeeper/color_tokens.py +697 -0
  26. trackerkeeper/credentials.py +471 -0
  27. trackerkeeper/custom_tooltip.py +254 -0
  28. trackerkeeper/dashboard.py +704 -0
  29. trackerkeeper/deliver.py +442 -0
  30. trackerkeeper/design_tokens.py +415 -0
  31. trackerkeeper/diagnostics.py +168 -0
  32. trackerkeeper/drag_repaint/__init__.py +68 -0
  33. trackerkeeper/drag_repaint/_kwin.py +192 -0
  34. trackerkeeper/drag_repaint/_unsupported.py +41 -0
  35. trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
  36. trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
  37. trackerkeeper/frosted_dialog.py +377 -0
  38. trackerkeeper/i18n/__init__.py +101 -0
  39. trackerkeeper/i18n/fmt.py +137 -0
  40. trackerkeeper/icon_button.py +137 -0
  41. trackerkeeper/icons.py +532 -0
  42. trackerkeeper/identity.py +135 -0
  43. trackerkeeper/item_dialog.py +185 -0
  44. trackerkeeper/kde_titlebar.py +196 -0
  45. trackerkeeper/keyboard_focus.py +353 -0
  46. trackerkeeper/log.py +112 -0
  47. trackerkeeper/macos_menubar.py +282 -0
  48. trackerkeeper/macos_window.py +172 -0
  49. trackerkeeper/metadata.py +121 -0
  50. trackerkeeper/noborder/__init__.py +61 -0
  51. trackerkeeper/noborder/_kwin.py +247 -0
  52. trackerkeeper/noborder/_unsupported.py +42 -0
  53. trackerkeeper/notifications/__init__.py +84 -0
  54. trackerkeeper/notifications/_linux.py +68 -0
  55. trackerkeeper/notifications/_macos.py +159 -0
  56. trackerkeeper/notifications/_unsupported.py +22 -0
  57. trackerkeeper/notifications/_windows.py +115 -0
  58. trackerkeeper/platform_compat.py +149 -0
  59. trackerkeeper/power/__init__.py +84 -0
  60. trackerkeeper/power/_linux.py +79 -0
  61. trackerkeeper/power/_unsupported.py +18 -0
  62. trackerkeeper/power/_windows.py +52 -0
  63. trackerkeeper/rig.py +338 -0
  64. trackerkeeper/scaffold.py +310 -0
  65. trackerkeeper/selector.py +529 -0
  66. trackerkeeper/settings.py +226 -0
  67. trackerkeeper/settings_dialog.py +307 -0
  68. trackerkeeper/settings_migration.py +101 -0
  69. trackerkeeper/single_instance.py +330 -0
  70. trackerkeeper/smooth_scroll.py +155 -0
  71. trackerkeeper/sources.py +318 -0
  72. trackerkeeper/system_accent.py +363 -0
  73. trackerkeeper/terminal.py +423 -0
  74. trackerkeeper/test_bridge.py +514 -0
  75. trackerkeeper/theme.py +725 -0
  76. trackerkeeper/top_bar.py +154 -0
  77. trackerkeeper/tray.py +184 -0
  78. trackerkeeper/ui_helpers.py +2294 -0
  79. trackerkeeper/update_chip.py +119 -0
  80. trackerkeeper/updates.py +216 -0
  81. trackerkeeper/win_frameless.py +364 -0
  82. trackerkeeper/window.py +609 -0
  83. trackerkeeper/windows_shortcut.py +355 -0
  84. trackerkeeper-0.1.0.dist-info/METADATA +144 -0
  85. trackerkeeper-0.1.0.dist-info/RECORD +89 -0
  86. trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
  87. trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
  88. trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
  89. trackerkeeper-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,154 @@
1
+ """TopBar — the app's top bar, which doubles as the titlebar in borderless mode.
2
+
3
+ A compact strip modelled on the dough shell: a hamburger menu + settings on the
4
+ LEFT, then the app title and a status badge; on the right an app-fillable action
5
+ slot, the update chip, and (when trackerkeeper owns the chrome) the minimize /
6
+ maximize / close window controls. Dragging the bar moves the window
7
+ (``startSystemMove``); double-click toggles maximize.
8
+
9
+ Apps fold their OWN header controls onto this one line instead of a second row:
10
+ ``insert_title_widget()`` drops a badge beside the title, ``add_action()`` adds a
11
+ right-side control (a button, a status label), and ``add_menu_action()`` extends
12
+ the hamburger menu. See :class:`~trackerkeeper.dashboard.Dashboard`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from PySide6.QtCore import Qt
18
+ from PySide6.QtWidgets import QHBoxLayout, QLabel
19
+
20
+ from trackerkeeper import ui_helpers
21
+ from trackerkeeper.bus import AppBus
22
+ from trackerkeeper.design_tokens import TYPE_SUBHEAD, type_qss
23
+ from trackerkeeper.icon_button import IconButton
24
+ from trackerkeeper.icons import icon
25
+
26
+
27
+ class TopBar(ui_helpers.CenteredBar):
28
+ HEIGHT = 44
29
+ # The chrome-button footprint. Exported so app controls folded onto this bar
30
+ # (see Dashboard) can match it instead of guessing.
31
+ BUTTON_SIZE = (30, 26)
32
+
33
+ def __init__(self, window, *, titlebar_mode: bool, title: str = "trackerkeeper"):
34
+ super().__init__(window)
35
+ self._window = window
36
+ self._titlebar_mode = titlebar_mode
37
+ self._menu_actions: list = []
38
+ self.setFixedHeight(self.HEIGHT)
39
+ self.setStyleSheet("background: transparent;")
40
+
41
+ lay = QHBoxLayout(self)
42
+ lay.setContentsMargins(8, 5, 8, 5)
43
+ lay.setSpacing(5)
44
+
45
+ # ── left: hamburger menu + settings ──
46
+ self.menu_btn = self._chrome_button("menu", "Menu")
47
+ self.menu_btn.clicked.connect(self._open_menu)
48
+ lay.addWidget(self.menu_btn)
49
+ self.settings_btn = self._chrome_button("settings", "Settings")
50
+ self.settings_btn.clicked.connect(lambda: AppBus.get().show_settings.emit())
51
+ lay.addWidget(self.settings_btn)
52
+
53
+ # ── title, then a slot right after it for an app badge (update count) ──
54
+ self.title = QLabel(title)
55
+ lay.addWidget(self.title)
56
+ self._title_slot = QHBoxLayout()
57
+ self._title_slot.setContentsMargins(4, 0, 0, 0)
58
+ self._title_slot.setSpacing(6)
59
+ lay.addLayout(self._title_slot)
60
+
61
+ lay.addStretch(1)
62
+
63
+ # ── right: an app action slot (buttons / a status label) ──
64
+ self._action_slot = QHBoxLayout()
65
+ self._action_slot.setContentsMargins(0, 0, 0, 0)
66
+ self._action_slot.setSpacing(6)
67
+ lay.addLayout(self._action_slot)
68
+
69
+ # Update-available chip — invisible until trackerkeeper.updates finds a newer
70
+ # release (AppBus.update_available); Download / What's-new / Dismiss.
71
+ from trackerkeeper.update_chip import UpdateChip
72
+
73
+ self.update_chip = UpdateChip(self)
74
+ lay.addWidget(self.update_chip)
75
+
76
+ # Window controls only when we own the titlebar (borderless / frameless).
77
+ if titlebar_mode:
78
+ self.min_btn = self._chrome_button("win_minimize", "Minimize")
79
+ self.min_btn.clicked.connect(window.showMinimized)
80
+ self.max_btn = self._chrome_button("win_maximize", "Maximize")
81
+ self.max_btn.clicked.connect(self._toggle_max)
82
+ self.close_btn = self._chrome_button("win_close", "Close")
83
+ self.close_btn.clicked.connect(window.close)
84
+ for b in (self.min_btn, self.max_btn, self.close_btn):
85
+ lay.addWidget(b)
86
+
87
+ self.restyle()
88
+
89
+ # ── the app-fills-the-bar API ──────────────────────────────────────────
90
+ def insert_title_widget(self, widget) -> None:
91
+ """Add a widget immediately after the title (e.g. an update-count badge)."""
92
+ self._title_slot.addWidget(widget)
93
+
94
+ def add_action(self, widget) -> None:
95
+ """Append an app control to the right-side slot — left of the update chip
96
+ and the window buttons."""
97
+ self._action_slot.addWidget(widget)
98
+
99
+ def add_menu_action(self, label: str, callback) -> None:
100
+ """Add an entry to the hamburger menu (above the always-present Settings)."""
101
+ self._menu_actions.append((label, callback))
102
+
103
+ def _open_menu(self) -> None:
104
+ menu = ui_helpers.opaque_menu(self, blur_corner_radius=8)
105
+ for label, cb in self._menu_actions:
106
+ menu.addAction(label).triggered.connect(lambda _=False, c=cb: c())
107
+ if self._menu_actions:
108
+ menu.addSeparator()
109
+ menu.addAction("Settings…").triggered.connect(
110
+ lambda _=False: AppBus.get().show_settings.emit())
111
+ menu.exec(self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomLeft()))
112
+
113
+ # ── chrome ─────────────────────────────────────────────────────────────
114
+ def _chrome_button(self, icon_name: str, tip: str) -> IconButton:
115
+ # accessible_name passed explicitly (the preferred pattern) even though
116
+ # the tooltip would fall back to the same string.
117
+ b = IconButton(accessible_name=tip)
118
+ b.setIcon(icon(icon_name))
119
+ b.setToolTip(tip)
120
+ b.setFixedSize(*self.BUTTON_SIZE) # compact, dough-matched (was 36×32)
121
+ return b
122
+
123
+ def restyle(self) -> None:
124
+ """Re-read theme colors (call on AppBus.theme_changed) + refresh icons."""
125
+ self.title.setStyleSheet(f"color: {ui_helpers.TEXT}; {type_qss(TYPE_SUBHEAD)}")
126
+ for name, attr in (
127
+ ("menu", "menu_btn"),
128
+ ("settings", "settings_btn"),
129
+ ("win_minimize", "min_btn"),
130
+ ("win_maximize", "max_btn"),
131
+ ("win_close", "close_btn"),
132
+ ):
133
+ btn = getattr(self, attr, None)
134
+ if btn is not None:
135
+ btn.setIcon(icon(name))
136
+
137
+ def _toggle_max(self) -> None:
138
+ w = self._window
139
+ w.showNormal() if w.isMaximized() else w.showMaximized()
140
+
141
+ # ── Drag-to-move / double-click maximize (titlebar) ────────────────────
142
+ def mousePressEvent(self, e):
143
+ if self._titlebar_mode and e.button() == Qt.MouseButton.LeftButton:
144
+ handle = self._window.windowHandle()
145
+ if handle is not None:
146
+ handle.startSystemMove()
147
+ return
148
+ super().mousePressEvent(e)
149
+
150
+ def mouseDoubleClickEvent(self, e):
151
+ if self._titlebar_mode and e.button() == Qt.MouseButton.LeftButton:
152
+ self._toggle_max()
153
+ return
154
+ super().mouseDoubleClickEvent(e)
trackerkeeper/tray.py ADDED
@@ -0,0 +1,184 @@
1
+ """The system-tray presence — tracker keeper's resting state.
2
+
3
+ A watchtower is only useful if it's *watching*, which means living in the tray
4
+ rather than in a window you have to keep open. :class:`AppTray` puts the app
5
+ there: an icon whose tooltip carries the current update count, a menu (Show /
6
+ Check for updates / Settings / Quit), left-click to toggle the window, and an
7
+ optional close-to-tray so the X button hides instead of quitting.
8
+
9
+ Everything is best-effort and self-disabling: if the desktop has no tray
10
+ (``isSystemTrayAvailable()`` False — headless, some wlroots sessions), nothing
11
+ is created, ``available`` stays False, and the app behaves exactly as before —
12
+ the close button never traps the window in an invisible process.
13
+
14
+ Preferences live under the documented extension path (``get_settings()._s``), so
15
+ no base settings file is touched:
16
+ ``app/show_tray_icon`` and ``app/close_to_tray``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+
23
+ from PySide6.QtCore import QEvent, QObject
24
+ from PySide6.QtGui import QIcon
25
+ from PySide6.QtWidgets import QApplication, QSystemTrayIcon
26
+
27
+ from trackerkeeper.bus import AppBus
28
+ from trackerkeeper.settings import get_settings
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _KEY_SHOW = "app/show_tray_icon"
33
+ _KEY_CLOSE = "app/close_to_tray"
34
+
35
+
36
+ def _as_bool(val, default: bool) -> bool:
37
+ if val is None:
38
+ return default
39
+ if isinstance(val, bool):
40
+ return val
41
+ return str(val).strip().lower() in ("1", "true", "yes", "on")
42
+
43
+
44
+ def show_tray_icon() -> bool:
45
+ return _as_bool(get_settings()._s.value(_KEY_SHOW), True)
46
+
47
+
48
+ def set_show_tray_icon(v: bool) -> None:
49
+ get_settings()._s.setValue(_KEY_SHOW, bool(v))
50
+
51
+
52
+ def close_to_tray() -> bool:
53
+ """Close hides to the tray instead of quitting. On by default for a
54
+ watchtower — but only ever honoured while a tray icon is actually live."""
55
+ return _as_bool(get_settings()._s.value(_KEY_CLOSE), True)
56
+
57
+
58
+ def set_close_to_tray(v: bool) -> None:
59
+ get_settings()._s.setValue(_KEY_CLOSE, bool(v))
60
+
61
+
62
+ def tooltip_text(app_name: str, n_updates: int) -> str:
63
+ """The hover text: the fleet's headline, no window needed."""
64
+ if n_updates <= 0:
65
+ return f"{app_name} — all current"
66
+ return f"{app_name} — {n_updates} update{'s' if n_updates != 1 else ''} available"
67
+
68
+
69
+ class AppTray(QObject):
70
+ """The tray icon + its menu. Construct with the main window; call
71
+ :meth:`set_update_count` whenever the fleet's update total changes."""
72
+
73
+ def __init__(self, window, *, app_name: str = "tracker keeper",
74
+ on_refresh=None, parent=None):
75
+ super().__init__(parent or window)
76
+ self._window = window
77
+ self._app_name = app_name
78
+ self._on_refresh = on_refresh
79
+ self._quitting = False
80
+ self._icon: QSystemTrayIcon | None = None
81
+
82
+ if not (show_tray_icon() and QSystemTrayIcon.isSystemTrayAvailable()):
83
+ return
84
+
85
+ from trackerkeeper.ui_helpers import make_app_icon, opaque_menu
86
+
87
+ self._icon = QSystemTrayIcon(QIcon(make_app_icon(64)), self)
88
+ self._icon.setToolTip(tooltip_text(app_name, 0))
89
+
90
+ menu = opaque_menu(window, blur_corner_radius=8)
91
+ self._show_action = menu.addAction("Show tracker keeper")
92
+ self._show_action.triggered.connect(lambda _=False: self._toggle_window())
93
+ if on_refresh is not None:
94
+ menu.addAction("Check for updates").triggered.connect(
95
+ lambda _=False: on_refresh())
96
+ menu.addSeparator()
97
+ menu.addAction("Settings…").triggered.connect(
98
+ lambda _=False: AppBus.get().show_settings.emit())
99
+ menu.addAction("Quit").triggered.connect(lambda _=False: self.quit())
100
+ self._menu = menu
101
+ self._icon.setContextMenu(menu)
102
+ self._icon.activated.connect(self._on_activated)
103
+ self._icon.show()
104
+
105
+ # Hiding the last window must NOT end the process once we live in the
106
+ # tray — the app's whole point is to keep watching.
107
+ app = QApplication.instance()
108
+ if app is not None:
109
+ app.setQuitOnLastWindowClosed(False)
110
+ window.installEventFilter(self)
111
+
112
+ # ── state ──────────────────────────────────────────────────────────────
113
+ @property
114
+ def available(self) -> bool:
115
+ """True when a tray icon is actually live (desktop supports it and the
116
+ user hasn't turned it off). Everything else no-ops when False."""
117
+ return self._icon is not None
118
+
119
+ def set_update_count(self, n: int) -> None:
120
+ """Reflect the fleet's update total in the tooltip."""
121
+ if self._icon is not None:
122
+ self._icon.setToolTip(tooltip_text(self._app_name, n))
123
+
124
+ def notify(self, title: str, body: str) -> None:
125
+ """A tray balloon — the fallback when the OS notification backend
126
+ isn't available."""
127
+ if self._icon is not None:
128
+ self._icon.showMessage(title, body, QSystemTrayIcon.MessageIcon.Information)
129
+
130
+ # ── actions ────────────────────────────────────────────────────────────
131
+ def _on_activated(self, reason) -> None:
132
+ if reason in (QSystemTrayIcon.ActivationReason.Trigger,
133
+ QSystemTrayIcon.ActivationReason.DoubleClick):
134
+ self._toggle_window()
135
+
136
+ def _toggle_window(self) -> None:
137
+ w = self._window
138
+ if w.isVisible() and not w.isMinimized():
139
+ self._hide_window()
140
+ else:
141
+ from trackerkeeper.single_instance import force_foreground
142
+
143
+ w.showNormal()
144
+ force_foreground(w)
145
+
146
+ def _hide_window(self) -> None:
147
+ # Geometry is normally persisted by the window's closeEvent — hiding
148
+ # skips that, so save it here or a tray-only session forgets its size.
149
+ try:
150
+ get_settings().save_geometry(self._window)
151
+ except Exception:
152
+ logger.debug("save_geometry on hide-to-tray failed", exc_info=True)
153
+ self._window.hide()
154
+
155
+ def quit(self) -> None:
156
+ """Really exit (the tray's Quit) — bypasses close-to-tray."""
157
+ self._quitting = True
158
+ try:
159
+ get_settings().save_geometry(self._window)
160
+ except Exception:
161
+ logger.debug("save_geometry on quit failed", exc_info=True)
162
+ if self._icon is not None:
163
+ self._icon.hide()
164
+ app = QApplication.instance()
165
+ if app is not None:
166
+ app.quit()
167
+
168
+ # ── close-to-tray ──────────────────────────────────────────────────────
169
+ def eventFilter(self, obj, event): # noqa: N802 (Qt override)
170
+ # Guard order matters: bail out on the no-tray / quitting cases BEFORE
171
+ # touching the event, so the filter is inert (and safe) when there's no
172
+ # tray to hide into.
173
+ if (
174
+ self.available
175
+ and not self._quitting
176
+ and obj is self._window
177
+ and close_to_tray()
178
+ and event is not None
179
+ and event.type() == QEvent.Type.Close
180
+ ):
181
+ event.ignore()
182
+ self._hide_window()
183
+ return True
184
+ return False