usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
menubar_title.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""Menu bar title rendering."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from AppKit import NSAttributedString, NSFont, NSFontAttributeName, NSMutableAttributedString
|
|
14
|
+
|
|
15
|
+
import critter_frames
|
|
16
|
+
from menubar_chrome import (
|
|
17
|
+
_agy_menubar_icon,
|
|
18
|
+
_claude_menubar_icon,
|
|
19
|
+
_codex_menubar_icon,
|
|
20
|
+
_critter_frame_image,
|
|
21
|
+
_critter_icon_attachment_string,
|
|
22
|
+
_menubar_icon_attachment_string,
|
|
23
|
+
)
|
|
24
|
+
from menubar_state import PopoverState, _format_percent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _TitleApp(Protocol):
|
|
28
|
+
_menubar_text_cache: dict[str, Any]
|
|
29
|
+
critter_frame: int
|
|
30
|
+
dragon_frame: int
|
|
31
|
+
lion_frame: int
|
|
32
|
+
codex_5h_pct: float | None
|
|
33
|
+
_last_button_title_key: tuple[str, int | None, int | None, int | None] | None
|
|
34
|
+
_last_plain_title_key: tuple[str] | None
|
|
35
|
+
status_item: Any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _menubar_text_string(app: _TitleApp, text: str) -> Any:
|
|
39
|
+
cached = app._menubar_text_cache.get(text)
|
|
40
|
+
if cached is not None:
|
|
41
|
+
return cached
|
|
42
|
+
attributed = NSAttributedString.alloc().initWithString_attributes_(
|
|
43
|
+
text,
|
|
44
|
+
{NSFontAttributeName: NSFont.menuBarFontOfSize_(0)},
|
|
45
|
+
)
|
|
46
|
+
app._menubar_text_cache[text] = attributed
|
|
47
|
+
return attributed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _menubar_attributed_title(app: _TitleApp, state: PopoverState) -> Any:
|
|
51
|
+
title = NSMutableAttributedString.alloc().init()
|
|
52
|
+
phoenix_frame = int(app.critter_frame) % len(critter_frames.PHOENIX_FRAMES)
|
|
53
|
+
dragon_frame = int(app.dragon_frame) % len(critter_frames.DRAGON_FRAMES)
|
|
54
|
+
lion_frame = int(app.lion_frame) % len(critter_frames.LION_FRAMES)
|
|
55
|
+
if not state.hide_claude:
|
|
56
|
+
claude_percent = (
|
|
57
|
+
"--"
|
|
58
|
+
if state.claude_session.percent is None
|
|
59
|
+
else f"{_format_percent(state.claude_session.percent)}%"
|
|
60
|
+
)
|
|
61
|
+
title.appendAttributedString_(_menubar_icon_attachment_string(_claude_menubar_icon()))
|
|
62
|
+
title.appendAttributedString_(_menubar_text_string(app, f" {claude_percent}"))
|
|
63
|
+
phoenix_path = critter_frames.PHOENIX_FRAMES[phoenix_frame]
|
|
64
|
+
phoenix = _critter_frame_image(phoenix_path)
|
|
65
|
+
if phoenix is not None:
|
|
66
|
+
title.appendAttributedString_(_menubar_text_string(app, " "))
|
|
67
|
+
title.appendAttributedString_(
|
|
68
|
+
_critter_icon_attachment_string(phoenix_path, phoenix)
|
|
69
|
+
)
|
|
70
|
+
if not state.hide_codex and (app.codex_5h_pct is not None or state.hide_claude):
|
|
71
|
+
codex_percent = (
|
|
72
|
+
"--"
|
|
73
|
+
if app.codex_5h_pct is None
|
|
74
|
+
else f"{_format_percent(float(app.codex_5h_pct))}%"
|
|
75
|
+
)
|
|
76
|
+
if not state.hide_claude:
|
|
77
|
+
title.appendAttributedString_(_menubar_text_string(app, " · "))
|
|
78
|
+
title.appendAttributedString_(_menubar_icon_attachment_string(_codex_menubar_icon()))
|
|
79
|
+
title.appendAttributedString_(_menubar_text_string(app, f" {codex_percent}"))
|
|
80
|
+
dragon_path = critter_frames.DRAGON_FRAMES[dragon_frame]
|
|
81
|
+
dragon = _critter_frame_image(dragon_path)
|
|
82
|
+
if dragon is not None:
|
|
83
|
+
title.appendAttributedString_(_menubar_text_string(app, " "))
|
|
84
|
+
title.appendAttributedString_(
|
|
85
|
+
_critter_icon_attachment_string(dragon_path, dragon)
|
|
86
|
+
)
|
|
87
|
+
agy_session_percent = state.agy_session.percent
|
|
88
|
+
agy_visible = not state.hide_agy and agy_session_percent is not None
|
|
89
|
+
if agy_visible:
|
|
90
|
+
assert agy_session_percent is not None
|
|
91
|
+
agy_percent = f"{_format_percent(agy_session_percent)}%"
|
|
92
|
+
if title.length() > 0:
|
|
93
|
+
title.appendAttributedString_(_menubar_text_string(app, " · "))
|
|
94
|
+
title.appendAttributedString_(_menubar_icon_attachment_string(_agy_menubar_icon()))
|
|
95
|
+
title.appendAttributedString_(_menubar_text_string(app, f" {agy_percent}"))
|
|
96
|
+
lion_path = critter_frames.LION_FRAMES[lion_frame]
|
|
97
|
+
lion = _critter_frame_image(lion_path)
|
|
98
|
+
if lion is not None:
|
|
99
|
+
title.appendAttributedString_(_menubar_text_string(app, " "))
|
|
100
|
+
title.appendAttributedString_(
|
|
101
|
+
_critter_icon_attachment_string(lion_path, lion)
|
|
102
|
+
)
|
|
103
|
+
if title.length() == 0:
|
|
104
|
+
# Both providers hidden: keep a recognizable, clickable status item.
|
|
105
|
+
title.appendAttributedString_(_menubar_icon_attachment_string(_claude_menubar_icon()))
|
|
106
|
+
return title
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _set_button_title(app: _TitleApp, state: PopoverState) -> None:
|
|
110
|
+
title = _compose_title(app, state)
|
|
111
|
+
phoenix_frame = int(app.critter_frame) if not state.hide_claude else None
|
|
112
|
+
dragon_visible = not state.hide_codex and (
|
|
113
|
+
app.codex_5h_pct is not None or state.hide_claude
|
|
114
|
+
)
|
|
115
|
+
dragon_frame = int(app.dragon_frame) if dragon_visible else None
|
|
116
|
+
lion_visible = not state.hide_agy and state.agy_session.percent is not None
|
|
117
|
+
lion_frame = int(app.lion_frame) if lion_visible else None
|
|
118
|
+
title_key = (title, phoenix_frame, dragon_frame, lion_frame)
|
|
119
|
+
if app._last_button_title_key == title_key:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
button = app.status_item.button()
|
|
123
|
+
# setTitle: forces a full NSStatusItem relayout (_adjustLength) that
|
|
124
|
+
# cascades into the popover's frame/corner-mask recompute even while
|
|
125
|
+
# it's hidden. attributedTitle is what's actually rendered, so during
|
|
126
|
+
# critter/dragon/lion animation (their frame values changing every
|
|
127
|
+
# 0.10-0.18s) only push the plain title when its own text changed —
|
|
128
|
+
# not on every sprite frame — to avoid paying that relayout per frame.
|
|
129
|
+
plain_key = (title,)
|
|
130
|
+
if app._last_plain_title_key != plain_key:
|
|
131
|
+
button.setTitle_(title)
|
|
132
|
+
app._last_plain_title_key = plain_key
|
|
133
|
+
button.setAttributedTitle_(_menubar_attributed_title(app, state))
|
|
134
|
+
app._last_button_title_key = title_key
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _compose_title(app: _TitleApp, state: PopoverState) -> str:
|
|
138
|
+
parts: list[str] = []
|
|
139
|
+
if not state.hide_claude:
|
|
140
|
+
claude = (
|
|
141
|
+
"--"
|
|
142
|
+
if state.claude_session.percent is None
|
|
143
|
+
else f"{_format_percent(state.claude_session.percent)}%"
|
|
144
|
+
)
|
|
145
|
+
parts.append(f"🐾 {claude}")
|
|
146
|
+
if not state.hide_codex and (app.codex_5h_pct is not None or state.hide_claude):
|
|
147
|
+
codex = (
|
|
148
|
+
"--"
|
|
149
|
+
if app.codex_5h_pct is None
|
|
150
|
+
else f"{_format_percent(float(app.codex_5h_pct))}%"
|
|
151
|
+
)
|
|
152
|
+
parts.append(f"📜 {codex}")
|
|
153
|
+
if not state.hide_agy and state.agy_session.percent is not None:
|
|
154
|
+
agy = f"{_format_percent(state.agy_session.percent)}%"
|
|
155
|
+
parts.append(f"🦁 {agy}")
|
|
156
|
+
# Both providers hidden: keep a recognizable, clickable status item.
|
|
157
|
+
return " · ".join(parts) if parts else "🐾"
|
menubar_update.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""Background-safe release-check scheduling and update-cache maintenance."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
import update_checker
|
|
16
|
+
import update_gate
|
|
17
|
+
import usage_diagnosis_snapshot
|
|
18
|
+
from menubar_prefs import _auto_update_check_enabled
|
|
19
|
+
from prefs import _load_preferences, _save_preferences
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _UpdateApp(Protocol):
|
|
25
|
+
def performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
26
|
+
self, selector: str, obj: Any, wait: bool
|
|
27
|
+
) -> None: ...
|
|
28
|
+
|
|
29
|
+
def _check_update_in_background(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
manual: bool,
|
|
33
|
+
ignore_cooldown: bool,
|
|
34
|
+
ignore_skipped: bool,
|
|
35
|
+
) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clear_stale_update_cache() -> None:
|
|
39
|
+
from menubar import _current_version
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
current_version = _current_version()
|
|
43
|
+
prefs = _load_preferences()
|
|
44
|
+
updated_cache = update_gate.stale_cache_reset(prefs, current_version)
|
|
45
|
+
if updated_cache is not None:
|
|
46
|
+
prefs["last_update_check"] = updated_cache
|
|
47
|
+
_save_preferences(prefs)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def maybe_check_update_in_background(app: _UpdateApp) -> None:
|
|
53
|
+
usage_diagnosis_snapshot.maybe_schedule_refresh()
|
|
54
|
+
app._check_update_in_background(
|
|
55
|
+
manual=False,
|
|
56
|
+
ignore_cooldown=False,
|
|
57
|
+
ignore_skipped=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_update_in_background(
|
|
62
|
+
app: _UpdateApp,
|
|
63
|
+
*,
|
|
64
|
+
manual: bool,
|
|
65
|
+
ignore_cooldown: bool,
|
|
66
|
+
ignore_skipped: bool,
|
|
67
|
+
) -> None:
|
|
68
|
+
from menubar import _current_version
|
|
69
|
+
|
|
70
|
+
prefs = _load_preferences()
|
|
71
|
+
if not manual and not _auto_update_check_enabled(prefs):
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if not manual and not update_gate.auto_check_is_due(prefs):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if not ignore_cooldown and update_gate.dismissed_recently(prefs):
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
current_version = _current_version()
|
|
82
|
+
check_result = update_checker.check_latest_release_result(current_version)
|
|
83
|
+
except Exception:
|
|
84
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
85
|
+
logger.warning("update check failed", exc_info=True)
|
|
86
|
+
if manual:
|
|
87
|
+
app.performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
88
|
+
"_showUpdateCheckFailed:",
|
|
89
|
+
None,
|
|
90
|
+
False,
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if check_result.failed:
|
|
95
|
+
if manual:
|
|
96
|
+
app.performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
97
|
+
"_showUpdateCheckFailed:",
|
|
98
|
+
None,
|
|
99
|
+
False,
|
|
100
|
+
)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
release = check_result.release
|
|
104
|
+
prefs["last_update_check"] = update_gate.build_check_cache_entry(current_version, release)
|
|
105
|
+
_save_preferences(prefs)
|
|
106
|
+
|
|
107
|
+
if release is None:
|
|
108
|
+
if manual:
|
|
109
|
+
app.performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
110
|
+
"_showNoUpdateAvailable:",
|
|
111
|
+
None,
|
|
112
|
+
False,
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if not ignore_skipped and prefs.get("update_skipped_version") == release.version:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
app.performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
120
|
+
"_showUpdateAlert:",
|
|
121
|
+
release,
|
|
122
|
+
False,
|
|
123
|
+
)
|
panel_window.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
# mypy: disable-error-code="import-untyped,misc"
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import objc
|
|
13
|
+
from AppKit import (
|
|
14
|
+
NSBackingStoreBuffered,
|
|
15
|
+
NSColor,
|
|
16
|
+
NSFloatingWindowLevel,
|
|
17
|
+
NSMakePoint,
|
|
18
|
+
NSPanel,
|
|
19
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
20
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
21
|
+
NSWindowStyleMaskBorderless,
|
|
22
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PANEL_CORNER_RADIUS = 12.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PanelWindow(NSPanel):
|
|
29
|
+
def initWithContentRect_(self, content_rect: Any) -> PanelWindow:
|
|
30
|
+
style_mask = int(NSWindowStyleMaskBorderless) | int(
|
|
31
|
+
NSWindowStyleMaskNonactivatingPanel
|
|
32
|
+
)
|
|
33
|
+
self = objc.super(PanelWindow, self).initWithContentRect_styleMask_backing_defer_(
|
|
34
|
+
content_rect,
|
|
35
|
+
style_mask,
|
|
36
|
+
NSBackingStoreBuffered,
|
|
37
|
+
False,
|
|
38
|
+
)
|
|
39
|
+
if self is None:
|
|
40
|
+
return None
|
|
41
|
+
self.setOpaque_(False)
|
|
42
|
+
self.setBackgroundColor_(NSColor.clearColor())
|
|
43
|
+
self.setHasShadow_(True)
|
|
44
|
+
self.setLevel_(NSFloatingWindowLevel)
|
|
45
|
+
self.setMovableByWindowBackground_(True)
|
|
46
|
+
self.setHidesOnDeactivate_(False)
|
|
47
|
+
self.setReleasedWhenClosed_(False)
|
|
48
|
+
behavior = int(NSWindowCollectionBehaviorCanJoinAllSpaces) | int(
|
|
49
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
50
|
+
)
|
|
51
|
+
self.setCollectionBehavior_(behavior)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def canBecomeKeyWindow(self) -> bool:
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def cancelOperation_(self, sender: Any) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
def setRoundedContentView_(self, view: Any) -> None:
|
|
61
|
+
view.setWantsLayer_(True)
|
|
62
|
+
layer = view.layer()
|
|
63
|
+
if layer is not None:
|
|
64
|
+
layer.setCornerRadius_(PANEL_CORNER_RADIUS)
|
|
65
|
+
layer.setMasksToBounds_(True)
|
|
66
|
+
self.setContentView_(view)
|
|
67
|
+
|
|
68
|
+
def setContentSizeKeepingTopLeft_(self, size: Any) -> None:
|
|
69
|
+
frame = self.frame()
|
|
70
|
+
top = float(frame.origin.y) + float(frame.size.height)
|
|
71
|
+
self.setContentSize_(size)
|
|
72
|
+
resized_frame = self.frame()
|
|
73
|
+
self.setFrameOrigin_(
|
|
74
|
+
NSMakePoint(
|
|
75
|
+
float(resized_frame.origin.x),
|
|
76
|
+
top - float(resized_frame.size.height),
|
|
77
|
+
)
|
|
78
|
+
)
|
panel_window_state.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
if sys.platform == "darwin":
|
|
15
|
+
from Foundation import NSUserDefaults
|
|
16
|
+
else:
|
|
17
|
+
NSUserDefaults = None
|
|
18
|
+
|
|
19
|
+
PANEL_WINDOW_ORIGIN_DEFAULTS_KEY = "usage.panelWindowOrigin"
|
|
20
|
+
PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY = "usage.panelWindowTopLeft"
|
|
21
|
+
PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY = "usage.panelContentHeights"
|
|
22
|
+
|
|
23
|
+
Origin = tuple[float, float]
|
|
24
|
+
Size = tuple[float, float]
|
|
25
|
+
VisibleFrame = tuple[float, float, float, float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clamp_origin_to_visible_frames(
|
|
29
|
+
origin: Origin,
|
|
30
|
+
size: Size,
|
|
31
|
+
visible_frames: Sequence[VisibleFrame],
|
|
32
|
+
) -> Origin:
|
|
33
|
+
if not visible_frames:
|
|
34
|
+
return origin
|
|
35
|
+
|
|
36
|
+
width = max(0.0, float(size[0]))
|
|
37
|
+
height = max(0.0, float(size[1]))
|
|
38
|
+
candidates: list[Origin] = []
|
|
39
|
+
for frame_x, frame_y, frame_width, frame_height in visible_frames:
|
|
40
|
+
maximum_x = float(frame_x) + max(0.0, float(frame_width) - width)
|
|
41
|
+
maximum_y = float(frame_y) + max(0.0, float(frame_height) - height)
|
|
42
|
+
candidate_x = min(max(float(origin[0]), float(frame_x)), maximum_x)
|
|
43
|
+
candidate_y = min(max(float(origin[1]), float(frame_y)), maximum_y)
|
|
44
|
+
candidates.append((candidate_x, candidate_y))
|
|
45
|
+
|
|
46
|
+
return min(
|
|
47
|
+
candidates,
|
|
48
|
+
key=lambda candidate: (candidate[0] - origin[0]) ** 2
|
|
49
|
+
+ (candidate[1] - origin[1]) ** 2,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_panel_window_origin(defaults: Any | None = None) -> Origin | None:
|
|
54
|
+
store = defaults
|
|
55
|
+
if store is None:
|
|
56
|
+
assert NSUserDefaults is not None
|
|
57
|
+
store = NSUserDefaults.standardUserDefaults()
|
|
58
|
+
value = store.arrayForKey_(PANEL_WINDOW_ORIGIN_DEFAULTS_KEY)
|
|
59
|
+
if not isinstance(value, Sequence) or isinstance(value, str | bytes) or len(value) != 2:
|
|
60
|
+
return None
|
|
61
|
+
x, y = value
|
|
62
|
+
if (
|
|
63
|
+
isinstance(x, bool)
|
|
64
|
+
or isinstance(y, bool)
|
|
65
|
+
or not isinstance(x, int | float)
|
|
66
|
+
or not isinstance(y, int | float)
|
|
67
|
+
or not math.isfinite(float(x))
|
|
68
|
+
or not math.isfinite(float(y))
|
|
69
|
+
):
|
|
70
|
+
return None
|
|
71
|
+
return (float(x), float(y))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_panel_window_top_left(
|
|
75
|
+
top_left: Origin,
|
|
76
|
+
defaults: Any | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
store = defaults
|
|
79
|
+
if store is None:
|
|
80
|
+
assert NSUserDefaults is not None
|
|
81
|
+
store = NSUserDefaults.standardUserDefaults()
|
|
82
|
+
store.setObject_forKey_(
|
|
83
|
+
[float(top_left[0]), float(top_left[1])],
|
|
84
|
+
PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_panel_window_top_left(defaults: Any | None = None) -> Origin | None:
|
|
89
|
+
store = defaults
|
|
90
|
+
if store is None:
|
|
91
|
+
assert NSUserDefaults is not None
|
|
92
|
+
store = NSUserDefaults.standardUserDefaults()
|
|
93
|
+
value = store.arrayForKey_(PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY)
|
|
94
|
+
if not isinstance(value, Sequence) or isinstance(value, str | bytes) or len(value) != 2:
|
|
95
|
+
return None
|
|
96
|
+
x, y = value
|
|
97
|
+
if (
|
|
98
|
+
isinstance(x, bool)
|
|
99
|
+
or isinstance(y, bool)
|
|
100
|
+
or not isinstance(x, int | float)
|
|
101
|
+
or not isinstance(y, int | float)
|
|
102
|
+
or not math.isfinite(float(x))
|
|
103
|
+
or not math.isfinite(float(y))
|
|
104
|
+
):
|
|
105
|
+
return None
|
|
106
|
+
return (float(x), float(y))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_panel_content_height(
|
|
110
|
+
panel_id: str,
|
|
111
|
+
height: float,
|
|
112
|
+
defaults: Any | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
store = defaults
|
|
115
|
+
if store is None:
|
|
116
|
+
assert NSUserDefaults is not None
|
|
117
|
+
store = NSUserDefaults.standardUserDefaults()
|
|
118
|
+
value = store.objectForKey_(PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
|
|
119
|
+
heights = dict(value) if isinstance(value, Mapping) else {}
|
|
120
|
+
heights[panel_id] = float(height)
|
|
121
|
+
store.setObject_forKey_(heights, PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_panel_content_height(
|
|
125
|
+
panel_id: str,
|
|
126
|
+
defaults: Any | None = None,
|
|
127
|
+
) -> float | None:
|
|
128
|
+
store = defaults
|
|
129
|
+
if store is None:
|
|
130
|
+
assert NSUserDefaults is not None
|
|
131
|
+
store = NSUserDefaults.standardUserDefaults()
|
|
132
|
+
heights = store.objectForKey_(PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
|
|
133
|
+
if not isinstance(heights, Mapping):
|
|
134
|
+
return None
|
|
135
|
+
value = heights.get(panel_id)
|
|
136
|
+
if (
|
|
137
|
+
isinstance(value, bool)
|
|
138
|
+
or not isinstance(value, int | float)
|
|
139
|
+
or not math.isfinite(float(value))
|
|
140
|
+
or value <= 0
|
|
141
|
+
):
|
|
142
|
+
return None
|
|
143
|
+
return float(value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_panel_size(
|
|
147
|
+
state: Any,
|
|
148
|
+
panel: Any | None,
|
|
149
|
+
defaults: Any | None = None,
|
|
150
|
+
) -> Size:
|
|
151
|
+
import menubar_state
|
|
152
|
+
|
|
153
|
+
width, height = menubar_state.popover_dimensions(state, panel)
|
|
154
|
+
measurement_available = getattr(panel, "_content_height_reports_available", True)
|
|
155
|
+
if measurement_available and panel is not None and (
|
|
156
|
+
saved_height := load_panel_content_height(panel.id, defaults)
|
|
157
|
+
):
|
|
158
|
+
height = saved_height
|
|
159
|
+
return (width, height)
|
panels/__init__.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import cache
|
|
10
|
+
|
|
11
|
+
from panels.base import Panel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cache
|
|
15
|
+
def all_panels() -> tuple[Panel, ...]:
|
|
16
|
+
# web_panel requires PyObjC at import time (objc/AppKit/WebKit), which only
|
|
17
|
+
# exists on macOS. Import it lazily so importing the panels package (e.g.
|
|
18
|
+
# wintray -> panels.payload on Windows) never pulls it in.
|
|
19
|
+
from panels.web_panel import HTMLPanel
|
|
20
|
+
|
|
21
|
+
# claude_card_height mirrors codex_card_height: the two cards share the same
|
|
22
|
+
# structure (header + two quota rows) and measure equal in headless renders.
|
|
23
|
+
return (
|
|
24
|
+
HTMLPanel(
|
|
25
|
+
"classic",
|
|
26
|
+
"panel_default_name",
|
|
27
|
+
"classic.html",
|
|
28
|
+
height=1004.0,
|
|
29
|
+
claude_card_height=192.0,
|
|
30
|
+
codex_card_height=192.0,
|
|
31
|
+
agy_card_height=192.0,
|
|
32
|
+
status_wrap_extra_height=30.0,
|
|
33
|
+
service_alert_height=32.0,
|
|
34
|
+
),
|
|
35
|
+
HTMLPanel(
|
|
36
|
+
"matrix",
|
|
37
|
+
"panel_matrix",
|
|
38
|
+
"matrix.html",
|
|
39
|
+
height=1046.0,
|
|
40
|
+
claude_card_height=200.0,
|
|
41
|
+
codex_card_height=200.0,
|
|
42
|
+
agy_card_height=200.0,
|
|
43
|
+
status_wrap_extra_height=32.0,
|
|
44
|
+
service_alert_height=32.0,
|
|
45
|
+
),
|
|
46
|
+
HTMLPanel(
|
|
47
|
+
"win95",
|
|
48
|
+
"panel_win95",
|
|
49
|
+
"win95.html",
|
|
50
|
+
height=1055.0,
|
|
51
|
+
claude_card_height=210.0,
|
|
52
|
+
codex_card_height=209.0,
|
|
53
|
+
agy_card_height=209.0,
|
|
54
|
+
status_wrap_extra_height=32.0,
|
|
55
|
+
service_alert_height=32.0,
|
|
56
|
+
),
|
|
57
|
+
HTMLPanel(
|
|
58
|
+
"newspaper",
|
|
59
|
+
"panel_newspaper",
|
|
60
|
+
"newspaper.html",
|
|
61
|
+
height=1051.0,
|
|
62
|
+
claude_card_height=205.0,
|
|
63
|
+
codex_card_height=203.0,
|
|
64
|
+
agy_card_height=203.0,
|
|
65
|
+
status_wrap_extra_height=30.0,
|
|
66
|
+
service_alert_height=32.0,
|
|
67
|
+
),
|
|
68
|
+
HTMLPanel(
|
|
69
|
+
"cloud_observation",
|
|
70
|
+
"panel_cloud_observation",
|
|
71
|
+
"cloud_observation.html",
|
|
72
|
+
height=1006.0,
|
|
73
|
+
claude_card_height=211.0,
|
|
74
|
+
codex_card_height=211.0,
|
|
75
|
+
agy_card_height=211.0,
|
|
76
|
+
service_alert_height=32.0,
|
|
77
|
+
),
|
|
78
|
+
HTMLPanel(
|
|
79
|
+
"aquarium",
|
|
80
|
+
"panel_aquarium",
|
|
81
|
+
"aquarium.html",
|
|
82
|
+
height=1006.0,
|
|
83
|
+
claude_card_height=211.0,
|
|
84
|
+
codex_card_height=211.0,
|
|
85
|
+
agy_card_height=211.0,
|
|
86
|
+
service_alert_height=32.0,
|
|
87
|
+
),
|
|
88
|
+
HTMLPanel(
|
|
89
|
+
"prism_arcade",
|
|
90
|
+
"panel_prism_arcade",
|
|
91
|
+
"prism_arcade.html",
|
|
92
|
+
height=1006.0,
|
|
93
|
+
claude_card_height=211.0,
|
|
94
|
+
codex_card_height=211.0,
|
|
95
|
+
agy_card_height=211.0,
|
|
96
|
+
service_alert_height=32.0,
|
|
97
|
+
),
|
|
98
|
+
# Reuse classic's measured values because the DOM structure is identical;
|
|
99
|
+
# remeasure if a future render shows clipping.
|
|
100
|
+
HTMLPanel(
|
|
101
|
+
"stained_glass",
|
|
102
|
+
"panel_stained_glass",
|
|
103
|
+
"stained_glass.html",
|
|
104
|
+
height=1004.0,
|
|
105
|
+
claude_card_height=192.0,
|
|
106
|
+
codex_card_height=192.0,
|
|
107
|
+
agy_card_height=192.0,
|
|
108
|
+
status_wrap_extra_height=30.0,
|
|
109
|
+
service_alert_height=32.0,
|
|
110
|
+
),
|
|
111
|
+
# Reuse classic's measured values because the DOM structure is identical;
|
|
112
|
+
# remeasure if a future render shows clipping.
|
|
113
|
+
HTMLPanel(
|
|
114
|
+
"origami",
|
|
115
|
+
"panel_origami",
|
|
116
|
+
"origami.html",
|
|
117
|
+
height=1004.0,
|
|
118
|
+
claude_card_height=192.0,
|
|
119
|
+
codex_card_height=192.0,
|
|
120
|
+
agy_card_height=192.0,
|
|
121
|
+
status_wrap_extra_height=30.0,
|
|
122
|
+
service_alert_height=32.0,
|
|
123
|
+
),
|
|
124
|
+
HTMLPanel(
|
|
125
|
+
"black_hole",
|
|
126
|
+
"panel_black_hole",
|
|
127
|
+
"black_hole.html",
|
|
128
|
+
height=1006.0,
|
|
129
|
+
claude_card_height=211.0,
|
|
130
|
+
codex_card_height=211.0,
|
|
131
|
+
agy_card_height=211.0,
|
|
132
|
+
service_alert_height=32.0,
|
|
133
|
+
),
|
|
134
|
+
HTMLPanel(
|
|
135
|
+
"lepidoptera",
|
|
136
|
+
"panel_lepidoptera",
|
|
137
|
+
"lepidoptera.html",
|
|
138
|
+
height=1046.0,
|
|
139
|
+
claude_card_height=208.0,
|
|
140
|
+
codex_card_height=208.0,
|
|
141
|
+
agy_card_height=208.0,
|
|
142
|
+
status_wrap_extra_height=32.0,
|
|
143
|
+
service_alert_height=32.0,
|
|
144
|
+
),
|
|
145
|
+
HTMLPanel(
|
|
146
|
+
"world_cup",
|
|
147
|
+
"panel_world_cup",
|
|
148
|
+
"world_cup.html",
|
|
149
|
+
claude_card_height=0.0,
|
|
150
|
+
codex_card_height=0.0,
|
|
151
|
+
service_alert_height=32.0,
|
|
152
|
+
),
|
|
153
|
+
HTMLPanel(
|
|
154
|
+
"talent_market",
|
|
155
|
+
"panel_talent_market",
|
|
156
|
+
"talent_market.html",
|
|
157
|
+
height=812.0,
|
|
158
|
+
claude_card_height=0.0,
|
|
159
|
+
codex_card_height=0.0,
|
|
160
|
+
dynamic_height=False,
|
|
161
|
+
),
|
|
162
|
+
# 1038 = classic's 1004 + roughly 34px for the flavor swatches.
|
|
163
|
+
# This is estimated, not measured; verify clipping visually after packaging.
|
|
164
|
+
HTMLPanel(
|
|
165
|
+
"catppuccin",
|
|
166
|
+
"panel_catppuccin",
|
|
167
|
+
"catppuccin.html",
|
|
168
|
+
height=1038.0,
|
|
169
|
+
claude_card_height=192.0,
|
|
170
|
+
codex_card_height=192.0,
|
|
171
|
+
agy_card_height=192.0,
|
|
172
|
+
status_wrap_extra_height=30.0,
|
|
173
|
+
service_alert_height=32.0,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def panel_ids() -> tuple[str, ...]:
|
|
179
|
+
return tuple(panel.id for panel in all_panels())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_panel(panel_id: str) -> Panel:
|
|
183
|
+
for panel in all_panels():
|
|
184
|
+
if panel.id == panel_id:
|
|
185
|
+
return panel
|
|
186
|
+
return all_panels()[0]
|